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 }