133728e barerepo 1mo
1
// Package repocfg reads .barerepo/config with the namespace's authority, not the file's. Chapter 14.
133728e barerepo 1mo
2
package repocfg
133728e barerepo 1mo
4
import (
133728e barerepo 1mo
5
"context"
133728e barerepo 1mo
6
"slices"
133728e barerepo 1mo
7
"strings"
133728e barerepo 1mo
9
"github.com/BurntSushi/toml"
133728e barerepo 1mo
11
"github.com/barerepo/server/internal/cache"
133728e barerepo 1mo
12
"github.com/barerepo/server/internal/gitx"
133728e barerepo 1mo
15
// Path is where the file lives, in every repository, always.
133728e barerepo 1mo
16
const Path = ".barerepo/config"
133728e barerepo 1mo
18
type Config struct {
133728e barerepo 1mo
19
Repo Repo `toml:"repo"`
133728e barerepo 1mo
20
Access Access `toml:"access"`
133728e barerepo 1mo
21
Proposals Proposals `toml:"proposals"`
133728e barerepo 1mo
22
Runners map[string][]string `toml:"runners"`
133728e barerepo 1mo
23
Build Build `toml:"build"`
133728e barerepo 1mo
24
Webhook []Webhook `toml:"webhook"`
133728e barerepo 1mo
25
// FellBackTo names the commit whose settings are in force when the tip's file does not parse.
133728e barerepo 1mo
26
FellBackTo string `toml:"-"`
133728e barerepo 1mo
29
type Repo struct {
133728e barerepo 1mo
30
DefaultBranch string `toml:"default_branch"`
133728e barerepo 1mo
31
Visibility string `toml:"visibility"`
133728e barerepo 1mo
32
Description string `toml:"description"`
133728e barerepo 1mo
33
Archived bool `toml:"archived"`
133728e barerepo 1mo
36
type Access struct {
133728e barerepo 1mo
37
Push []string `toml:"push"`
133728e barerepo 1mo
38
AllowForcePush []string `toml:"allow_force_push"`
133728e barerepo 1mo
39
AllowDelete []string `toml:"allow_delete"`
133728e barerepo 1mo
40
// RequireSigned refuses a branch or tag carrying a commit with no signature, and is off unless a repository asks for it.
133728e barerepo 1mo
41
RequireSigned bool `toml:"require_signed_commits"`
133728e barerepo 1mo
44
type Proposals struct {
133728e barerepo 1mo
45
AcceptFrom string `toml:"accept_from"`
133728e barerepo 1mo
46
RequireRuns []string `toml:"require_runs"`
133728e barerepo 1mo
47
ExpireDays int `toml:"expire_days"`
133728e barerepo 1mo
50
type Build struct {
133728e barerepo 1mo
51
Command string `toml:"command"`
133728e barerepo 1mo
52
Image string `toml:"image"`
133728e barerepo 1mo
55
type Webhook struct {
133728e barerepo 1mo
56
URL string `toml:"url"`
133728e barerepo 1mo
57
Events []string `toml:"events"`
133728e barerepo 1mo
58
SecretEnv string `toml:"secret_env"`
133728e barerepo 1mo
61
// Default leaves visibility empty, which reads as private, because absent must be the safe answer.
133728e barerepo 1mo
62
func Default() Config {
133728e barerepo 1mo
63
return Config{
133728e barerepo 1mo
64
Proposals: Proposals{AcceptFrom: "anyone", ExpireDays: 180},
133728e barerepo 1mo
68
// Load returns the defaults and the parse error together, because a bad file must not lock anyone out.
133728e barerepo 1mo
69
func Load(ctx context.Context, dir string) (Config, error) {
133728e barerepo 1mo
70
cfg := Default()
133728e barerepo 1mo
72
// Keyed by the commit the branch points at, which is a file read rather than a process.
133728e barerepo 1mo
73
key := ""
133728e barerepo 1mo
74
if branch, err := gitx.HeadBranch(dir); err == nil {
133728e barerepo 1mo
75
if sha, err := gitx.ResolveRef(dir, "refs/heads/"+branch); err == nil {
133728e barerepo 1mo
76
key = sha
133728e barerepo 1mo
79
if key != "" {
133728e barerepo 1mo
80
if body, ok := Cache.Get("config", key); ok {
133728e barerepo 1mo
81
if len(body) == 0 {
133728e barerepo 1mo
82
return cfg, nil // no config file at this commit
133728e barerepo 1mo
84
if _, err := toml.Decode(string(body), &cfg); err != nil {
133728e barerepo 1mo
85
return lastGood(ctx, dir, key), err
133728e barerepo 1mo
87
return cfg, nil
133728e barerepo 1mo
91
obj, err := gitx.CatFile(ctx, dir, "HEAD:"+Path)
133728e barerepo 1mo
92
if err != nil || obj.Type != "blob" {
133728e barerepo 1mo
93
// No file, no HEAD, or an empty repository. All mean "defaults".
133728e barerepo 1mo
94
if key != "" {
133728e barerepo 1mo
95
Cache.Put("config", key, nil)
133728e barerepo 1mo
97
return cfg, nil
133728e barerepo 1mo
99
if key != "" {
133728e barerepo 1mo
100
Cache.Put("config", key, []byte(obj.Body))
133728e barerepo 1mo
101
}
133728e barerepo 1mo
102
if strings.TrimSpace(obj.Body) == "" {
133728e barerepo 1mo
103
return cfg, nil
133728e barerepo 1mo
104
}
133728e barerepo 1mo
105
if _, err := toml.Decode(obj.Body, &cfg); err != nil {
133728e barerepo 1mo
106
return lastGood(ctx, dir, key), err
133728e barerepo 1mo
107
}
133728e barerepo 1mo
108
return cfg, nil
133728e barerepo 1mo
111
// lastGood is chapter 14's fallback, because the defaults lock out everyone the file let in.
133728e barerepo 1mo
112
func lastGood(ctx context.Context, dir, key string) Config {
133728e barerepo 1mo
113
if key != "" && Cache != nil {
133728e barerepo 1mo
114
if body, ok := Cache.Get("configgood", key); ok {
133728e barerepo 1mo
115
return decodeGood(body)
133728e barerepo 1mo
116
}
133728e barerepo 1mo
117
}
133728e barerepo 1mo
118
body := walkBack(ctx, dir)
133728e barerepo 1mo
119
if key != "" && Cache != nil {
133728e barerepo 1mo
120
Cache.Put("configgood", key, body)
133728e barerepo 1mo
121
}
133728e barerepo 1mo
122
return decodeGood(body)
133728e barerepo 1mo
125
// decodeGood reads a version already known to parse, and no version is the defaults.
133728e barerepo 1mo
126
func decodeGood(body []byte) Config {
133728e barerepo 1mo
127
cfg := Default()
133728e barerepo 1mo
128
if len(body) == 0 {
133728e barerepo 1mo
129
return cfg
133728e barerepo 1mo
130
}
133728e barerepo 1mo
131
sha, rest, _ := strings.Cut(string(body), "\n")
133728e barerepo 1mo
132
if _, err := toml.Decode(rest, &cfg); err != nil {
133728e barerepo 1mo
133
return Default()
133728e barerepo 1mo
134
}
133728e barerepo 1mo
135
cfg.FellBackTo = sha
133728e barerepo 1mo
136
return cfg
133728e barerepo 1mo
139
// walkBack reads the file's own history newest first and stops at the first version that parses.
133728e barerepo 1mo
140
func walkBack(ctx context.Context, dir string) []byte {
133728e barerepo 1mo
141
// Bounded, because a file that has never parsed must not cost a walk of the whole history.
133728e barerepo 1mo
142
out, err := gitx.Run(ctx, dir, "log", "--format=%H", "-n", "25", "HEAD", "--", Path)
133728e barerepo 1mo
143
if err != nil {
133728e barerepo 1mo
144
return nil
133728e barerepo 1mo
145
}
133728e barerepo 1mo
146
for _, sha := range strings.Fields(out) {
133728e barerepo 1mo
147
obj, err := gitx.CatFile(ctx, dir, sha+":"+Path)
133728e barerepo 1mo
148
if err != nil || obj.Type != "blob" {
133728e barerepo 1mo
149
continue
133728e barerepo 1mo
150
}
133728e barerepo 1mo
151
var cfg Config
133728e barerepo 1mo
152
if _, err := toml.Decode(obj.Body, &cfg); err != nil {
133728e barerepo 1mo
153
continue
133728e barerepo 1mo
154
}
133728e barerepo 1mo
155
// The sha rides on the first line, so the page and the terminal can name what is in force.
133728e barerepo 1mo
156
return []byte(sha + "\n" + obj.Body)
133728e barerepo 1mo
157
}
133728e barerepo 1mo
158
return nil
133728e barerepo 1mo
161
// Cache holds parsed configuration by commit, and nil means read it every time.
133728e barerepo 1mo
162
var Cache *cache.Disk
133728e barerepo 1mo
164
// List renders a toml array the way the file holds it, so the page and the terminal never differ.
133728e barerepo 1mo
165
func List(items []string) string {
133728e barerepo 1mo
166
if len(items) == 0 {
133728e barerepo 1mo
167
return "[]"
133728e barerepo 1mo
168
}
133728e barerepo 1mo
169
quoted := make([]string, len(items))
133728e barerepo 1mo
170
for i, s := range items {
133728e barerepo 1mo
171
quoted[i] = `"` + s + `"`
133728e barerepo 1mo
172
}
133728e barerepo 1mo
173
return "[" + strings.Join(quoted, ", ") + "]"
133728e barerepo 1mo
176
// Who names the pusher, where nobody is a reader who never signed in.
133728e barerepo 1mo
177
func Who(account string) string {
133728e barerepo 1mo
178
if account == "" {
133728e barerepo 1mo
179
return "not signed in"
133728e barerepo 1mo
180
}
133728e barerepo 1mo
181
return account
133728e barerepo 1mo
184
// Public opens only on the exact word, because a typo must never publish someone's code.
133728e barerepo 1mo
185
func (c Config) Public() bool { return strings.EqualFold(c.Repo.Visibility, "public") }
133728e barerepo 1mo
187
// MayRead answers for user, where empty is an anonymous reader. Chapter 18.
133728e barerepo 1mo
188
func (c Config) MayRead(owner, user string) bool {
133728e barerepo 1mo
189
return c.Public() || (user != "" && (user == owner || c.mayPushRef(owner, user)))
133728e barerepo 1mo
192
// MayPush covers heads and tags, and the owner is always allowed and never listed. Chapter 18.
133728e barerepo 1mo
193
func (c Config) MayPush(owner, user string) bool {
133728e barerepo 1mo
194
if c.Repo.Archived && user != owner {
133728e barerepo 1mo
195
return false
133728e barerepo 1mo
196
}
133728e barerepo 1mo
197
return c.mayPushRef(owner, user)
133728e barerepo 1mo
200
func (c Config) mayPushRef(owner, user string) bool {
133728e barerepo 1mo
201
if user == "" {
133728e barerepo 1mo
202
return false
133728e barerepo 1mo
203
}
133728e barerepo 1mo
204
return user == owner || slices.Contains(c.Access.Push, user)
133728e barerepo 1mo
207
// MayPropose reads [proposals] accept_from, and rule 5 defaults it to every signed-in user.
133728e barerepo 1mo
208
func (c Config) MayPropose(owner, user string) bool {
133728e barerepo 1mo
209
if c.Repo.Archived {
133728e barerepo 1mo
210
return false
133728e barerepo 1mo
211
}
133728e barerepo 1mo
212
if !c.MayRead(owner, user) {
133728e barerepo 1mo
213
return false
133728e barerepo 1mo
214
}
133728e barerepo 1mo
215
switch strings.ToLower(c.Proposals.AcceptFrom) {
133728e barerepo 1mo
216
case "push":
133728e barerepo 1mo
217
return c.mayPushRef(owner, user)
133728e barerepo 1mo
218
case "authenticated", "anyone", "":
133728e barerepo 1mo
219
// "anyone" means anyone with an account, since every push is authenticated to get here.