package core
import (
"fmt"
"strings"
"unicode/utf8"
)
const (
// MaxOwnerLen bounds owner names. meta.sr.ht already bounds them at
// registration, so this is a sanity cap rather than the authority.
MaxOwnerLen = 64
// MaxSpaceNameLen bounds space names. A space name becomes a directory
// under the repos root and a URL segment, so it stays short.
MaxSpaceNameLen = 100
// MaxPathLen bounds a document or attachment path within a space. Well
// under any filesystem limit; the point is to keep a hostile path out of
// the index and the render cache, not to be permissive.
MaxPathLen = 512
)
const (
// DocExt is the only extension a document may carry. Matched
// case-sensitively: git trees are case-sensitive, so accepting ".MD" would
// create documents that the renderer and indexer disagree about.
DocExt = ".md"
// PolicyFile is the space policy, versioned in the space itself so that
// policy changes are reviewable like any other change.
PolicyFile = ".spec.yml"
)
// SpaceRef identifies a space: one bare git repo, owned by one user. Both
// fields are stored without decoration — Owner never carries the leading '~'.
type SpaceRef struct {
Owner string
Name string
}
// String renders the canonical URL and on-disk form, "~owner/name".
func (r SpaceRef) String() string { return "~" + r.Owner + "/" + r.Name }
// isNameByte reports whether c is allowed in an owner or space name: lowercase
// alphanumerics plus '_', '-' and '.'. '/' is deliberately excluded, so a name
// can never span path components.
func isNameByte(c byte) bool {
switch {
case c >= 'a' && c <= 'z':
return true
case c >= '0' && c <= '9':
return true
case c == '_' || c == '-' || c == '.':
return true
default:
return false
}
}
// validateName holds the rules shared by owners and spaces: non-empty, within
// the allowed byte set, not starting with '-' (which would read as an option to
// anything shelling out) and containing no ".." (path traversal, since both
// kinds of name become a path segment under the repos root).
func validateName(kind, s string, maxLen int) error {
if s == "" {
return fmt.Errorf("%w: empty %s", ErrInvalidName, kind)
}
if len(s) > maxLen {
return fmt.Errorf("%w: %s %q is too long (%d > %d)", ErrInvalidName, kind, s, len(s), maxLen)
}
if s[0] == '-' {
return fmt.Errorf("%w: %s %q must not start with '-'", ErrInvalidName, kind, s)
}
if strings.Contains(s, "..") {
return fmt.Errorf("%w: %s %q must not contain '..'", ErrInvalidName, kind, s)
}
// '.' is in the allowed byte set, so the bare current-directory name has to
// be excluded by hand: a space named "." would resolve to the repos root.
if s == "." {
return fmt.Errorf("%w: %s %q is not allowed", ErrInvalidName, kind, s)
}
for i := 0; i < len(s); i++ {
if !isNameByte(s[i]) {
return fmt.Errorf("%w: %s %q contains disallowed byte %q", ErrInvalidName, kind, s, s[i])
}
}
return nil
}
// ValidateOwner reports whether s is a well-formed sourcehut owner name (the
// part after '~' in a URL). Callers must strip the leading '~' first.
func ValidateOwner(s string) error { return validateName("owner", s, MaxOwnerLen) }
// ValidateSpaceName reports whether s is a well-formed space name — the same
// character family as an owner, capped at MaxSpaceNameLen.
func ValidateSpaceName(s string) error { return validateName("space", s, MaxSpaceNameLen) }
// ParseSpaceRef parses "~owner/name" (or "owner/name") into a validated
// SpaceRef. Surrounding slashes are tolerated because the same string arrives
// both as a URL path and as a config value, but anything else that does not
// split into exactly two non-empty segments is rejected rather than repaired.
func ParseSpaceRef(s string) (SpaceRef, error) {
trimmed := strings.Trim(s, "/")
if trimmed == "" {
return SpaceRef{}, fmt.Errorf("%w: empty space reference", ErrInvalidName)
}
segs := strings.Split(trimmed, "/")
if len(segs) != 2 {
return SpaceRef{}, fmt.Errorf("%w: space reference %q must have exactly 2 segments, got %d",
ErrInvalidName, s, len(segs))
}
ref := SpaceRef{Owner: strings.TrimPrefix(segs[0], "~"), Name: segs[1]}
if err := ValidateOwner(ref.Owner); err != nil {
return SpaceRef{}, err
}
if err := ValidateSpaceName(ref.Name); err != nil {
return SpaceRef{}, err
}
return ref, nil
}
// badPathRune reports whether r must never appear in a path. Two families:
// control characters, which git tolerates in a tree entry but which corrupt
// logs, JSON and the index; and the Unicode bidirectional overrides, which can
// make a path render in the review UI as something other than what will be
// committed. Reviewing agent output is the product, so a path that lies about
// itself on screen is a correctness bug, not a nicety.
func badPathRune(r rune) bool {
if r < 0x20 || r == 0x7f {
return true
}
switch r {
case 0x200e, 0x200f, // LRM, RLM
0x202a, 0x202b, 0x202c, 0x202d, 0x202e, // LRE, RLE, PDF, LRO, RLO
0x2066, 0x2067, 0x2068, 0x2069: // LRI, RLI, FSI, PDI
return true
}
return false
}
// ValidatePath reports whether p is a safe relative path inside a space, usable
// as a git tree path for a document or an attachment. The rules:
//
// - non-empty and no longer than MaxPathLen;
// - valid UTF-8, no control characters, no bidi overrides (see badPathRune);
// - relative: no leading '/', no trailing '/';
// - no backslashes — on a git tree a '\' is an ordinary filename byte, so
// accepting it produces paths that mean different things to different
// clients;
// - no empty, "." or ".." components: traversal, and the whole point of this
// function;
// - no ".git" component, which git refuses to track and which is the classic
// checkout-escape vector;
// - no component ending in '.' or ' ', which are invisible on screen and
// therefore an easy way to shadow an existing document.
//
// ValidatePath deliberately allows dotfiles (".spec.yml" is one) and non-ASCII
// letters (specs here are written in Russian as well as English).
func ValidatePath(p string) error {
if p == "" {
return fmt.Errorf("%w: empty path", ErrInvalidPath)
}
if len(p) > MaxPathLen {
return fmt.Errorf("%w: path is too long (%d > %d)", ErrInvalidPath, len(p), MaxPathLen)
}
if !utf8.ValidString(p) {
return fmt.Errorf("%w: path is not valid UTF-8", ErrInvalidPath)
}
for _, r := range p {
if badPathRune(r) {
return fmt.Errorf("%w: path %q contains disallowed rune %U", ErrInvalidPath, p, r)
}
}
if strings.HasPrefix(p, "/") {
return fmt.Errorf("%w: path %q must be relative", ErrInvalidPath, p)
}
if strings.HasSuffix(p, "/") {
return fmt.Errorf("%w: path %q must not end in '/'", ErrInvalidPath, p)
}
if strings.Contains(p, `\`) {
return fmt.Errorf("%w: path %q must not contain a backslash", ErrInvalidPath, p)
}
for _, comp := range strings.Split(p, "/") {
switch comp {
case "":
return fmt.Errorf("%w: path %q has an empty component", ErrInvalidPath, p)
case ".", "..":
return fmt.Errorf("%w: path %q has a traversal component %q", ErrInvalidPath, p, comp)
case ".git":
return fmt.Errorf("%w: path %q has a %q component", ErrInvalidPath, p, comp)
}
if strings.HasSuffix(comp, ".") || strings.HasSuffix(comp, " ") {
return fmt.Errorf("%w: path %q component %q ends in '.' or a space", ErrInvalidPath, p, comp)
}
}
return nil
}
// ValidateDocPath reports whether p is a valid path for a markdown document:
// everything ValidatePath requires, plus a ".md" extension on a non-empty base
// name. The extension carries meaning here — it is what tells the indexer and
// the renderer that a blob is a document rather than an attachment — so a
// document named exactly ".md" is rejected as having no name at all.
func ValidateDocPath(p string) error {
if err := ValidatePath(p); err != nil {
return err
}
if !strings.HasSuffix(p, DocExt) {
return fmt.Errorf("%w: document path %q must end in %q", ErrInvalidPath, p, DocExt)
}
base := p
if i := strings.LastIndex(p, "/"); i >= 0 {
base = p[i+1:]
}
if base == DocExt {
return fmt.Errorf("%w: document path %q has an empty base name", ErrInvalidPath, p)
}
return nil
}