package main import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "regexp" "strings" "time" ) // The forge as this bundle reaches it: its own HTTP interface on the machine, as the forge's admin account, // which the vault delivered into a file this module owns (novox/hq ADR 0086), by basic authentication — the // same way the npm-registry bundle beside it does. The password is read when a call is made and never logged, // answered or put in an error. // Forge is the forge's HTTP interface. type Forge struct { URL string // e.g. http://127.0.0.1:3000 User string PasswordFile string HTTP *http.Client } // ForgeFromEnv is the forge as the manifest hands it to this bundle. func ForgeFromEnv() (*Forge, error) { f := &Forge{ URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"), User: os.Getenv("MESH_GITEA_ADMIN_USER"), PasswordFile: os.Getenv("MESH_GITEA_ADMIN_PASSWORD_FILE"), } var missing []string for _, kv := range [][2]string{{"MESH_GITEA_URL", f.URL}, {"MESH_GITEA_ADMIN_USER", f.User}, {"MESH_GITEA_ADMIN_PASSWORD_FILE", f.PasswordFile}} { if kv[1] == "" { missing = append(missing, kv[0]) } } if len(missing) > 0 { return nil, fmt.Errorf("the forge cannot be reached: %s not set by the manifest", strings.Join(missing, ", ")) } return f, nil } // errNotFound is the forge answering 404. var errNotFound = errors.New("not found") // statusError is an answer the forge gave that is not a success, said without the request's credential. type statusError struct { method, path string status int said string } func (e *statusError) Error() string { return fmt.Sprintf("the forge answered %d to %s %s: %s", e.status, e.method, e.path, e.said) } func (f *Forge) client() *http.Client { if f.HTTP != nil { return f.HTTP } return &http.Client{Timeout: 50 * time.Second} } func (f *Forge) password() (string, error) { raw, err := os.ReadFile(f.PasswordFile) if err != nil { // The path is the manifest's, not a secret; the content never appears. return "", fmt.Errorf("the forge's admin password cannot be read from its file: %w", err) } return strings.TrimSpace(string(raw)), nil } // maxBody is the largest JSON answer read from the forge; maxArchive the largest archive code search reads. const ( maxBody = 32 << 20 maxArchive = 64 << 20 ) // do sends one request and answers the body, read up to limit; 404 is errNotFound. A body the limit cut is // refused, never read as if it were whole. func (f *Forge) do(ctx context.Context, method, path string, payload any, limit int64) ([]byte, error) { password, err := f.password() if err != nil { return nil, err } var body io.Reader if payload != nil { raw, err := json.Marshal(payload) if err != nil { return nil, err } body = bytes.NewReader(raw) } req, err := http.NewRequestWithContext(ctx, method, f.URL+path, body) if err != nil { return nil, err } req.SetBasicAuth(f.User, password) req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } res, err := f.client().Do(req) if err != nil { return nil, fmt.Errorf("the forge did not answer %s %s: %w", method, path, err) } defer res.Body.Close() got, err := io.ReadAll(io.LimitReader(res.Body, limit+1)) if err != nil { return nil, err } if int64(len(got)) > limit { return nil, fmt.Errorf("the forge's answer to %s %s is larger than %d MiB and was not read whole", method, path, limit>>20) } switch { case res.StatusCode == http.StatusNotFound: return nil, fmt.Errorf("%s %s: %w", method, path, errNotFound) case res.StatusCode >= 300: said := strings.TrimSpace(string(got)) if len(said) > 300 { said = said[:300] + "…" } return nil, &statusError{method, path, res.StatusCode, said} } return got, nil } func (f *Forge) getJSON(ctx context.Context, path string, into any) error { return f.sendJSON(ctx, http.MethodGet, path, nil, into) } func (f *Forge) sendJSON(ctx context.Context, method, path string, payload, into any) error { body, err := f.do(ctx, method, path, payload, maxBody) if err != nil { return err } if into == nil { return nil } if err := json.Unmarshal(body, into); err != nil { return fmt.Errorf("the forge's answer to %s %s is not readable: %w", method, path, err) } return nil } // Refused is a refusal of policy or of an argument, in the words the caller sees. type Refused struct{ msg string } func (r *Refused) Error() string { return r.msg } func refuse(format string, a ...any) error { return &Refused{fmt.Sprintf(format, a...)} } var nameShape = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]*$`) // repoPath is /api/v1/repos/{owner}/{repo} for names that cannot become another path. func repoPath(owner, repo string) (string, error) { for _, v := range [][2]string{{"owner", owner}, {"name", repo}} { if !nameShape.MatchString(v[1]) || v[1] == "." || v[1] == ".." { return "", refuse("%q is not a repository %s", v[1], v[0]) } } return "/api/v1/repos/" + owner + "/" + repo, nil } // segments encodes a value that is several path segments — a branch like fix/1-x, a file's path — refusing // any segment that could make the request another path: empty, `.` or `..`, or holding `?`, `#`, `%`, `\` // or a control character (the same rule as the TypeScript bundle's pathSegments, guard.ts). func segments(what, value string) (string, error) { parts := strings.Split(value, "/") for i, p := range parts { if p == "" || p == "." || p == ".." || strings.ContainsAny(p, "?#%\\") || strings.IndexFunc(p, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 { return "", refuse("%q is not a %s this tool puts in a path. Nothing was sent", value, what) } parts[i] = url.PathEscape(p) } return strings.Join(parts, "/"), nil } // Repo is what the tools read of a repository. type Repo struct { FullName string `json:"full_name"` DefaultBranch string `json:"default_branch"` Empty bool `json:"empty"` } // Protection is one branch protection rule, as far as covering a branch goes. type Protection struct { RuleName string `json:"rule_name"` BranchName string `json:"branch_name"` } func (p Protection) name() string { if p.RuleName != "" { return p.RuleName } return p.BranchName } // ruleCovers is whether a protection rule's name — a branch, or a glob of them (`*`, `**`, `?`) — covers a // branch: the TypeScript bundle's ruleCovers (guard.ts), which gitea_api's file writes are judged by. func ruleCovers(rule, branch string) bool { if rule == branch { return true } if !strings.ContainsAny(rule, "*?[") { return false } var re strings.Builder re.WriteString("^") for i := 0; i < len(rule); i++ { c := rule[i] switch { case c == '*' && i+1 < len(rule) && rule[i+1] == '*': re.WriteString(".*") i++ case c == '*': re.WriteString("[^/]*") case c == '?': re.WriteString("[^/]") default: re.WriteString(regexp.QuoteMeta(string(c))) } } re.WriteString("$") ok, err := regexp.MatchString(re.String(), branch) return err == nil && ok } // Branch is one branch with the commit it points at. type Branch struct { Name string `json:"name"` Commit struct { ID string `json:"id"` } `json:"commit"` } // branch is the named branch, nil when the forge has none of that name. func (f *Forge) branch(ctx context.Context, repo, name string) (*Branch, error) { seg, err := segments("branch", name) if err != nil { return nil, err } var b Branch if err := f.getJSON(ctx, repo+"/branches/"+seg, &b); err != nil { if errors.Is(err, errNotFound) { return nil, nil } return nil, err } return &b, nil }