validators and error classes: fail-closed checks under every handler and git invocation
commit 2fcf91c586d2ff50542a9d87513f7d11ffdfe70e · parent 5152650dd9
· browse this tree
Ximianth, 2026-10-26 19:40:27 UTC
Three validators (repo names, refs, tree paths) and two error sentinels (errInvalid, errNotFound) that the serve layer will map to 400/404; gitError carries internal failure detail for everything else. Refs get a deliberately narrow charset: it admits branches, tags, HEAD, and hex shas while excluding all rev-parse operator syntax, so nothing that passes validation can mean anything to git but a plain ref. Tree paths stay charset-open (committed filenames are arbitrary) and only reject traversal, empty segments, and control bytes: they are only ever used after the colon of a <sha>:<path> argument, where git parses no options or rev syntax.
diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..0b45884 --- /dev/null +++ b/errors.go @@ -0,0 +1,53 @@ +package main + +import ( + "errors" + "fmt" + "strings" +) + +// Error classes for the plumbing layer. +// The serve layer maps these to HTTP statuses with errors.Is: +// errInvalid means the request was malformed (400), +// errNotFound means the asked-for thing doesn't exist (404), +// and anything else is internal (500). +var ( + // errInvalid marks input rejected by a validator: a malformed repo name, ref, or tree path. + errInvalid = errors.New("invalid") + // errNotFound marks a lookup that came back empty: an unknown repo, an unresolvable ref, or a path absent from a tree. + errNotFound = errors.New("not found") +) + +// gitError reports a git invocation that failed. +// Read operations translate the failures that mean "doesn't exist" into errNotFound; +// a gitError that escapes to a caller is an internal problem (missing binary, repo corruption, parse surprise). +type gitError struct { + args []string // the git argv, excluding the leading "git" itself + exitCode int // the process's exit code; -1 if it never ran + stderr string // trimmed stderr capture, for diagnostics + cause error // the underlying exec error, if any +} + +func (e *gitError) Error() string { + msg := fmt.Sprintf("git %s: exit %d", strings.Join(e.args, " "), e.exitCode) + if e.stderr != "" { + msg += ": " + e.stderr + } + if e.cause != nil { + msg += ": " + e.cause.Error() + } + return msg +} + +func (e *gitError) Unwrap() error { return e.cause } + +// notFoundOnExit converts a git failure into errNotFound when the process ran and exited nonzero: +// the operations that use this are ones where any clean nonzero exit means "no such ref/path". +// Failures to run at all (or errors that aren't gitError) pass through unchanged, staying internal. +func notFoundOnExit(err error, what string) error { + var ge *gitError + if errors.As(err, &ge) && ge.exitCode > 0 { + return fmt.Errorf("%w: %s", errNotFound, what) + } + return err +} diff --git a/validate.go b/validate.go new file mode 100644 index 0000000..1fc805c --- /dev/null +++ b/validate.go @@ -0,0 +1,113 @@ +package main + +import ( + "fmt" + "strings" +) + +// Validators for the names that arrive from the outside world: +// repo names (from config), refs (from URLs), and tree paths (from URLs). +// Every route handler and every git invocation funnels through these, and they fail closed: +// anything not positively allowed is rejected with errInvalid. +// +// Injection through argv is already impossible (git is exec'd with argument vectors, never a shell), +// so what these guard against is git misparsing an argument: +// options (leading dash), rev-parse operators (.. @{} ^ ~ :), and path traversal. + +// validateRepoName checks a configured repo name (which is also its URL prefix). +// Names are slash-separated segments of [A-Za-z0-9._-], with no segment +// empty, starting with a dot or a dash, or ending in ".git" +// (a leading dot covers rejecting ".", "..", and ".git" themselves; +// a ".git" suffix is rejected because URLs treat a trailing ".git" as an alias for the plain name, +// and a configured name ending that way would make the alias ambiguous). +func validateRepoName(name string) error { + if name == "" { + return fmt.Errorf("%w: repo name is empty", errInvalid) + } + for _, seg := range strings.Split(name, "/") { + if seg == "" { + return fmt.Errorf("%w: repo name %q: empty path segment", errInvalid, name) + } + if seg[0] == '.' || seg[0] == '-' { + return fmt.Errorf("%w: repo name %q: segment %q starts with %q", errInvalid, name, seg, seg[0:1]) + } + if strings.HasSuffix(seg, ".git") { + return fmt.Errorf("%w: repo name %q: segment %q ends in .git (reserved as a URL alias for the plain name)", errInvalid, name, seg) + } + for _, c := range seg { + if !nameChar(c) { + return fmt.Errorf("%w: repo name %q: character %q not allowed (name segments are [A-Za-z0-9._-])", errInvalid, name, c) + } + } + } + return nil +} + +func nameChar(c rune) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || + c == '.' || c == '_' || c == '-' +} + +// validateRef checks a ref name before it goes anywhere near a git invocation. +// The allowed shape is deliberately narrower than git's own ref rules: +// slash-separated segments of [A-Za-z0-9._-], no segment empty, starting with a dot, or ending in ".lock", +// no ".." anywhere, no leading dash, and at most 255 bytes. +// This admits branch names, tag names, "HEAD", and full hex shas, +// while excluding every rev-parse operator (ranges, reflog @{} syntax, ^ and ~ suffixes, :paths). +func validateRef(ref string) error { + if ref == "" { + return fmt.Errorf("%w: ref is empty", errInvalid) + } + if len(ref) > 255 { + return fmt.Errorf("%w: ref is unreasonably long (%d bytes)", errInvalid, len(ref)) + } + if ref[0] == '-' { + return fmt.Errorf("%w: ref %q starts with a dash", errInvalid, ref) + } + if strings.Contains(ref, "..") { + return fmt.Errorf("%w: ref %q contains %q", errInvalid, ref, "..") + } + for _, seg := range strings.Split(ref, "/") { + if seg == "" { + return fmt.Errorf("%w: ref %q: empty path segment", errInvalid, ref) + } + if seg[0] == '.' { + return fmt.Errorf("%w: ref %q: segment %q starts with a dot", errInvalid, ref, seg) + } + if strings.HasSuffix(seg, ".lock") { + return fmt.Errorf("%w: ref %q: segment %q ends in .lock", errInvalid, ref, seg) + } + for _, c := range seg { + if !nameChar(c) { + return fmt.Errorf("%w: ref %q: character %q not allowed", errInvalid, ref, c) + } + } + } + return nil +} + +// validateTreePath checks a path within a repo's tree. The empty string is valid and means the root. +// Unlike names and refs, the character set stays open (committed filenames are arbitrary: +// spaces, unicode, whatever), because a tree path is only ever used after a ":" in a <sha>:<path> argument, +// where git applies no option parsing or rev syntax. +// What's rejected: traversal dots, empty segments, leading or trailing slashes, and control bytes +// (never legitimate in a URL-supplied filename, and they'd make logs lie). +func validateTreePath(path string) error { + if path == "" { + return nil + } + for _, c := range path { + if c < 0x20 || c == 0x7f { + return fmt.Errorf("%w: path contains control byte 0x%02x", errInvalid, c) + } + } + for _, seg := range strings.Split(path, "/") { + if seg == "" { + return fmt.Errorf("%w: path %q: empty segment", errInvalid, path) + } + if seg == "." || seg == ".." { + return fmt.Errorf("%w: path %q: %q segment", errInvalid, path, seg) + } + } + return nil +} diff --git a/validate_test.go b/validate_test.go new file mode 100644 index 0000000..79d8f34 --- /dev/null +++ b/validate_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "errors" + "testing" +) + +func TestValidateRepoName(t *testing.T) { + t.Parallel() + runs(t, validateRepoName, []vcase{ + {"plain", "parnass", true}, + {"nested", "work/infra/deploy", true}, + {"dots and dashes inside", "a-b.c_d", true}, + {"digits", "repo2", true}, + {"empty", "", false}, + {"leading slash", "/foo", false}, + {"trailing slash", "foo/", false}, + {"double slash", "foo//bar", false}, + {"dot segment", "foo/./bar", false}, + {"dotdot segment", "foo/../bar", false}, + {"leading dot", ".hidden", false}, + {"leading dash", "-flag", false}, + {"leading dash in segment", "ok/-flag", false}, + {"git suffix", "foo.git", false}, + {"git suffix in segment", "foo.git/bar", false}, + {"bare .git segment", "foo/.git", false}, + {"space", "fo o", false}, + {"colon", "fo:o", false}, + {"unicode", "caf\u00e9", false}, + }) +} + +func TestValidateRef(t *testing.T) { + t.Parallel() + runs(t, validateRef, []vcase{ + {"branch", "main", true}, + {"HEAD", "HEAD", true}, + {"tag", "v1.0", true}, + {"slashed branch", "feature/shiny", true}, + {"full sha", "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", true}, + {"empty", "", false}, + {"leading dash", "-option", false}, + {"range", "main..dev", false}, + {"triple dot range", "main...dev", false}, + {"reflog syntax", "HEAD@{1}", false}, + {"parent suffix", "HEAD^", false}, + {"tilde suffix", "HEAD~2", false}, + {"colon path", "main:README.md", false}, + {"space", "ma in", false}, + {"dot-led segment", "foo/.bar", false}, + {"lock suffix", "main.lock", false}, + {"empty segment", "foo//bar", false}, + {"leading slash", "/main", false}, + {"trailing slash", "main/", false}, + {"overlong", string(make([]byte, 256)), false}, + }) +} + +func TestValidateTreePath(t *testing.T) { + t.Parallel() + runs(t, validateTreePath, []vcase{ + {"root", "", true}, + {"file", "README.md", true}, + {"nested", "docs/notes.md", true}, + {"dotfile", ".gitignore", true}, + {"space in name", "docs/with space.txt", true}, + {"unicode", "caf\u00e9/menu.md", true}, + {"leading dash", "-dashed", true}, // safe: paths ride after the colon of <sha>:<path> + {"dot segment", "./README.md", false}, + {"dotdot segment", "docs/../secret", false}, + {"bare dotdot", "..", false}, + {"leading slash", "/etc/passwd", false}, + {"trailing slash", "docs/", false}, + {"double slash", "docs//notes.md", false}, + {"newline", "do\ncs", false}, + {"nul", "do\x00cs", false}, + }) +} + +type vcase struct { + name string + input string + ok bool +} + +// runs drives one validator through a case table, +// checking both acceptance and that every rejection is an errInvalid. +func runs(t *testing.T, validate func(string) error, cases []vcase) { + t.Helper() + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := validate(c.input) + if c.ok && err != nil { + t.Errorf("%q: expected acceptance, got %v", c.input, err) + } + if !c.ok { + if err == nil { + t.Errorf("%q: expected rejection, got acceptance", c.input) + } else if !errors.Is(err, errInvalid) { + t.Errorf("%q: rejection should wrap errInvalid, got %v", c.input, err) + } + } + }) + } +}