// Package repocfg reads .barerepo/config with the namespace's authority, not the file's. Chapter 14. package repocfg import ( "context" "slices" "strings" "github.com/BurntSushi/toml" "github.com/barerepo/server/internal/cache" "github.com/barerepo/server/internal/gitx" ) // Path is where the file lives, in every repository, always. const Path = ".barerepo/config" type Config struct { Repo Repo `toml:"repo"` Access Access `toml:"access"` Proposals Proposals `toml:"proposals"` Runners map[string][]string `toml:"runners"` Build Build `toml:"build"` Webhook []Webhook `toml:"webhook"` // FellBackTo names the commit whose settings are in force when the tip's file does not parse. FellBackTo string `toml:"-"` } type Repo struct { DefaultBranch string `toml:"default_branch"` Visibility string `toml:"visibility"` Description string `toml:"description"` Archived bool `toml:"archived"` } type Access struct { Push []string `toml:"push"` AllowForcePush []string `toml:"allow_force_push"` AllowDelete []string `toml:"allow_delete"` // RequireSigned refuses a branch or tag carrying a commit with no signature, and is off unless a repository asks for it. RequireSigned bool `toml:"require_signed_commits"` } type Proposals struct { AcceptFrom string `toml:"accept_from"` RequireRuns []string `toml:"require_runs"` ExpireDays int `toml:"expire_days"` } type Build struct { Command string `toml:"command"` Image string `toml:"image"` } type Webhook struct { URL string `toml:"url"` Events []string `toml:"events"` SecretEnv string `toml:"secret_env"` } // Default leaves visibility empty, which reads as private, because absent must be the safe answer. func Default() Config { return Config{ Proposals: Proposals{AcceptFrom: "anyone", ExpireDays: 180}, } } // Load returns the defaults and the parse error together, because a bad file must not lock anyone out. func Load(ctx context.Context, dir string) (Config, error) { cfg := Default() // Keyed by the commit the branch points at, which is a file read rather than a process. key := "" if branch, err := gitx.HeadBranch(dir); err == nil { if sha, err := gitx.ResolveRef(dir, "refs/heads/"+branch); err == nil { key = sha } } if key != "" { if body, ok := Cache.Get("config", key); ok { if len(body) == 0 { return cfg, nil // no config file at this commit } if _, err := toml.Decode(string(body), &cfg); err != nil { return lastGood(ctx, dir, key), err } return cfg, nil } } obj, err := gitx.CatFile(ctx, dir, "HEAD:"+Path) if err != nil || obj.Type != "blob" { // No file, no HEAD, or an empty repository. All mean "defaults". if key != "" { Cache.Put("config", key, nil) } return cfg, nil } if key != "" { Cache.Put("config", key, []byte(obj.Body)) } if strings.TrimSpace(obj.Body) == "" { return cfg, nil } if _, err := toml.Decode(obj.Body, &cfg); err != nil { return lastGood(ctx, dir, key), err } return cfg, nil } // lastGood is chapter 14's fallback, because the defaults lock out everyone the file let in. func lastGood(ctx context.Context, dir, key string) Config { if key != "" && Cache != nil { if body, ok := Cache.Get("configgood", key); ok { return decodeGood(body) } } body := walkBack(ctx, dir) if key != "" && Cache != nil { Cache.Put("configgood", key, body) } return decodeGood(body) } // decodeGood reads a version already known to parse, and no version is the defaults. func decodeGood(body []byte) Config { cfg := Default() if len(body) == 0 { return cfg } sha, rest, _ := strings.Cut(string(body), "\n") if _, err := toml.Decode(rest, &cfg); err != nil { return Default() } cfg.FellBackTo = sha return cfg } // walkBack reads the file's own history newest first and stops at the first version that parses. func walkBack(ctx context.Context, dir string) []byte { // Bounded, because a file that has never parsed must not cost a walk of the whole history. out, err := gitx.Run(ctx, dir, "log", "--format=%H", "-n", "25", "HEAD", "--", Path) if err != nil { return nil } for _, sha := range strings.Fields(out) { obj, err := gitx.CatFile(ctx, dir, sha+":"+Path) if err != nil || obj.Type != "blob" { continue } var cfg Config if _, err := toml.Decode(obj.Body, &cfg); err != nil { continue } // The sha rides on the first line, so the page and the terminal can name what is in force. return []byte(sha + "\n" + obj.Body) } return nil } // Cache holds parsed configuration by commit, and nil means read it every time. var Cache *cache.Disk // List renders a toml array the way the file holds it, so the page and the terminal never differ. func List(items []string) string { if len(items) == 0 { return "[]" } quoted := make([]string, len(items)) for i, s := range items { quoted[i] = `"` + s + `"` } return "[" + strings.Join(quoted, ", ") + "]" } // Who names the pusher, where nobody is a reader who never signed in. func Who(account string) string { if account == "" { return "not signed in" } return account } // Public opens only on the exact word, because a typo must never publish someone's code. func (c Config) Public() bool { return strings.EqualFold(c.Repo.Visibility, "public") } // MayRead answers for user, where empty is an anonymous reader. Chapter 18. func (c Config) MayRead(owner, user string) bool { return c.Public() || (user != "" && (user == owner || c.mayPushRef(owner, user))) } // MayPush covers heads and tags, and the owner is always allowed and never listed. Chapter 18. func (c Config) MayPush(owner, user string) bool { if c.Repo.Archived && user != owner { return false } return c.mayPushRef(owner, user) } func (c Config) mayPushRef(owner, user string) bool { if user == "" { return false } return user == owner || slices.Contains(c.Access.Push, user) } // MayPropose reads [proposals] accept_from, and rule 5 defaults it to every signed-in user. func (c Config) MayPropose(owner, user string) bool { if c.Repo.Archived { return false } if !c.MayRead(owner, user) { return false } switch strings.ToLower(c.Proposals.AcceptFrom) { case "push": return c.mayPushRef(owner, user) case "authenticated", "anyone", "": // "anyone" means anyone with an account, since every push is authenticated to get here. return user != "" default: return user != "" } } // ForcePushAllowed permits every branch but the default, where the loss is not only the pusher's. func (c Config) ForcePushAllowed(branch, defaultBranch string) bool { return branch != defaultBranch || slices.Contains(c.Access.AllowForcePush, branch) } // DeleteAllowed reports whether branch may be deleted. func (c Config) DeleteAllowed(branch, defaultBranch string) bool { return branch != defaultBranch || slices.Contains(c.Access.AllowDelete, branch) } // ParseAt reads the file as one commit leaves it, so a typo is reported by the push that makes it. func ParseAt(ctx context.Context, dir, sha string) error { obj, err := gitx.CatFile(ctx, dir, sha+":"+Path) if err != nil || obj.Type != "blob" || strings.TrimSpace(obj.Body) == "" { // No file is not a broken file, and every repository without one runs on the defaults. return nil } var cfg Config _, err = toml.Decode(obj.Body, &cfg) return err }