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 // MaxProjectNameLen bounds project names. A project is a saved filter, not // a directory, so nothing on disk is named after it — but it is a URL // segment, so it stays as short as a space name. MaxProjectNameLen = 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 } const ( // ProjectSigil distinguishes a project from a space in one character: // "~owner/space" is a repository, "~owner/+project" is a saved filter over // a set of them. Like the '~' on an owner, it is decoration on the address // and is never part of the stored name. ProjectSigil = "+" // MetaProjectName is the reserved name of the meta-project, // "~owner/+everything" — the project whose membership is everything. // // It is an address, not a row. The design's meta-project is "a filter that // excludes nothing", with "no separate aggregate entity, no copying, and no // sync job"; a stored row would need one, because every new space would // have to be added to it and forgetting once would silently make the // meta-project incomplete. So this name resolves without touching storage, // and ValidateProjectName refuses it as a stored name — a row claiming it // could only shadow an address that already resolves. MetaProjectName = "everything" ) // ProjectRef identifies a project: a named set of spaces owned by one user. // Like SpaceRef, both fields are stored undecorated — Owner carries no '~' and // Name carries no '+'. type ProjectRef struct { Owner string Name string } // String renders the canonical URL form, "~owner/+name". func (r ProjectRef) String() string { return "~" + r.Owner + "/" + ProjectSigil + r.Name } // IsMeta reports whether r addresses the meta-project — the degenerate filter // that excludes nothing. It is the one project reference that resolves without // a stored row. func (r ProjectRef) IsMeta() bool { return r.Name == MetaProjectName } // ValidateProjectName reports whether s is a well-formed *storable* project // name: the same character family as a space name, capped at // MaxProjectNameLen, and not the reserved meta-project name. // // The reserved name fails with ErrReservedName rather than ErrInvalidName, // because it is well-formed and the caller is being told "that one is not // yours to create", not "that is not a name". Parsing a reference is a // different question — ParseProjectRef accepts "~bigbes/+everything", since it // is a perfectly good address; only storing it is refused. func ValidateProjectName(s string) error { if err := validateName("project", s, MaxProjectNameLen); err != nil { return err } if s == MetaProjectName { return fmt.Errorf("%w: project %q is the meta-project, which is a filter rather than a row", ErrReservedName, s) } return nil } // ParseProjectRef parses "~owner/+name" (or "owner/+name") into a validated // ProjectRef. The '+' is required: it is what keeps projects and spaces in one // URL namespace without either being able to shadow the other. // // Unlike ValidateProjectName this accepts the meta-project, which is an address // that resolves to a filter rather than to a row. func ParseProjectRef(s string) (ProjectRef, error) { trimmed := strings.Trim(s, "/") if trimmed == "" { return ProjectRef{}, fmt.Errorf("%w: empty project reference", ErrInvalidName) } segs := strings.Split(trimmed, "/") if len(segs) != 2 { return ProjectRef{}, fmt.Errorf("%w: project reference %q must have exactly 2 segments, got %d", ErrInvalidName, s, len(segs)) } if !strings.HasPrefix(segs[1], ProjectSigil) { return ProjectRef{}, fmt.Errorf("%w: project reference %q must name the project as %q", ErrInvalidName, s, ProjectSigil+"name") } ref := ProjectRef{ Owner: strings.TrimPrefix(segs[0], "~"), Name: strings.TrimPrefix(segs[1], ProjectSigil), } if err := ValidateOwner(ref.Owner); err != nil { return ProjectRef{}, err } if err := validateName("project", ref.Name, MaxProjectNameLen); err != nil { return ProjectRef{}, 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 }