2d862ecc14da09a6a21fa9b7c04f59e0ebe2111f

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

feat(web): add safe page primitives

Diff

This diff is truncated to protect this page.

  1diff --git a/go.mod b/go.mod
  2index d20b2543ed91e024530fc9b3f8f473fcd911d76f..4cfe73f1061680619aa0be16e852959ab77a334d 100644
  3--- a/go.mod
  4+++ b/go.mod
  5@@ -32,6 +32,7 @@ require (
  6 	github.com/lib/pq v1.12.3
  7 	github.com/lrstanley/bubblezone/v2 v2.0.0
  8 	github.com/matryer/is v1.4.1
  9+	github.com/microcosm-cc/bluemonday v1.0.27
 10 	github.com/muesli/mango-cobra v1.3.0
 11 	github.com/muesli/reflow v0.3.0
 12 	github.com/muesli/roff v0.1.0
 13@@ -40,6 +41,7 @@ require (
 14 	github.com/rogpeppe/go-internal v1.16.0
 15 	github.com/sergi/go-diff v1.4.0
 16 	github.com/spf13/cobra v1.10.2
 17+	github.com/yuin/goldmark v1.8.5
 18 	go.uber.org/automaxprocs v1.6.0
 19 	golang.org/x/crypto v0.54.0
 20 	golang.org/x/sync v0.22.0
 21@@ -74,7 +76,6 @@ require (
 22 	github.com/mattn/go-isatty v0.0.24 // indirect
 23 	github.com/mattn/go-runewidth v0.0.27 // indirect
 24 	github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2 // indirect
 25-	github.com/microcosm-cc/bluemonday v1.0.27 // indirect
 26 	github.com/muesli/cancelreader v0.2.2 // indirect
 27 	github.com/muesli/mango v0.2.0 // indirect
 28 	github.com/muesli/mango-pflag v0.2.0 // indirect
 29@@ -89,7 +90,6 @@ require (
 30 	github.com/sahilm/fuzzy v0.1.3 // indirect
 31 	github.com/spf13/pflag v1.0.10 // indirect
 32 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 33-	github.com/yuin/goldmark v1.8.5 // indirect
 34 	github.com/yuin/goldmark-emoji v1.0.6 // indirect
 35 	golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect
 36 	golang.org/x/net v0.57.0 // indirect
 37diff --git a/pkg/web/pages/access.go b/pkg/web/pages/access.go
 38new file mode 100644
 39index 0000000000000000000000000000000000000000..7892b73a89d3e94191cd6a93ee0d389eb636af58
 40--- /dev/null
 41+++ b/pkg/web/pages/access.go
 42@@ -0,0 +1,70 @@
 43+package pages
 44+
 45+import (
 46+	"context"
 47+	"errors"
 48+	"sort"
 49+
 50+	"github.com/charmbracelet/soft-serve/pkg/access"
 51+	"github.com/charmbracelet/soft-serve/pkg/backend"
 52+	"github.com/charmbracelet/soft-serve/pkg/proto"
 53+)
 54+
 55+// ErrNotFound is returned when a repository must not be disclosed to a page visitor.
 56+var ErrNotFound = errors.New("page resource not found")
 57+
 58+// IsPublicReadable reports whether repo may be shown to an anonymous page visitor.
 59+func IsPublicReadable(repo proto.Repository, level access.AccessLevel) bool {
 60+	return repo != nil && !repo.IsPrivate() && level >= access.ReadOnlyAccess
 61+}
 62+
 63+// PublicRepository loads a repository only when it is publicly readable.
 64+func PublicRepository(ctx context.Context, name string) (proto.Repository, error) {
 65+	be := backend.FromContext(ctx)
 66+	if be == nil {
 67+		return nil, ErrNotFound
 68+	}
 69+
 70+	repo, err := be.Repository(ctx, name)
 71+	if err != nil || !IsPublicReadable(repo, be.AccessLevelForUser(ctx, name, nil)) {
 72+		return nil, ErrNotFound
 73+	}
 74+	return repo, nil
 75+}
 76+
 77+// HomepageRepositories returns visible public repositories in display order.
 78+func HomepageRepositories(ctx context.Context) ([]proto.Repository, error) {
 79+	be := backend.FromContext(ctx)
 80+	if be == nil {
 81+		return nil, ErrNotFound
 82+	}
 83+
 84+	repos, err := be.Repositories(ctx)
 85+	if err != nil {
 86+		return nil, err
 87+	}
 88+
 89+	return FilterHomepageRepositories(repos, func(repo proto.Repository) bool {
 90+		return IsPublicReadable(repo, be.AccessLevelForUser(ctx, repo.Name(), nil))
 91+	}), nil
 92+}
 93+
 94+// FilterHomepageRepositories applies homepage visibility rules and display order.
 95+func FilterHomepageRepositories(repos []proto.Repository, canRead func(proto.Repository) bool) []proto.Repository {
 96+	visible := make([]proto.Repository, 0, len(repos))
 97+	for _, repo := range repos {
 98+		if repo.Name() == ".soft-serve" || repo.IsHidden() || !canRead(repo) {
 99+			continue
100+		}
101+		visible = append(visible, repo)
102+	}
103+
104+	sort.Slice(visible, func(i, j int) bool {
105+		left, right := visible[i], visible[j]
106+		if left.UpdatedAt().Equal(right.UpdatedAt()) {
107+			return left.Name() < right.Name()
108+		}
109+		return left.UpdatedAt().After(right.UpdatedAt())
110+	})
111+	return visible
112+}
113diff --git a/pkg/web/pages/content.go b/pkg/web/pages/content.go
114new file mode 100644
115index 0000000000000000000000000000000000000000..1c70034f649a499494e8a7d98cc6fe4b6f917385
116--- /dev/null
117+++ b/pkg/web/pages/content.go
118@@ -0,0 +1,99 @@
119+package pages
120+
121+import (
122+	"bytes"
123+	"errors"
124+	"fmt"
125+	"path"
126+	"strings"
127+
128+	"github.com/charmbracelet/soft-serve/git"
129+)
130+
131+const (
132+	// TextLimit is the maximum source size rendered in a page.
133+	TextLimit int64 = 1 << 20
134+	// RawLimit is the maximum source size served by the raw endpoint.
135+	RawLimit int64 = 16 << 20
136+)
137+
138+var (
139+	// ErrContentTooLarge means the entry was rejected before loading its blob.
140+	ErrContentTooLarge = errors.New("content exceeds page size limit")
141+	// ErrReadmeNotFound means no regular README blob was found.
142+	ErrReadmeNotFound = errors.New("readme not found")
143+)
144+
145+// BlobContent is a bounded blob and its binary classification.
146+type BlobContent struct {
147+	Bytes  []byte
148+	Size   int64
149+	Binary bool
150+}
151+
152+// LoadBlob checks an entry's Git-reported size before loading its bytes.
153+func LoadBlob(entry *git.TreeEntry, limit int64) (BlobContent, error) {
154+	if entry == nil || (!entry.IsBlob() && !entry.IsExec()) {
155+		return BlobContent{}, fmt.Errorf("entry is not a regular blob")
156+	}
157+	size := entry.Size()
158+	if size > limit {
159+		return BlobContent{}, ErrContentTooLarge
160+	}
161+	contents, err := entry.Contents()
162+	if err != nil {
163+		return BlobContent{}, err
164+	}
165+	// A Git object cannot change after its ID has been read, but keep the limit
166+	// invariant if a Git implementation reports an unexpected size.
167+	if int64(len(contents)) > limit {
168+		return BlobContent{}, ErrContentTooLarge
169+	}
170+	return BlobContent{Bytes: contents, Size: int64(len(contents)), Binary: IsBinary(contents)}, nil
171+}
172+
173+// IsBinary detects NUL bytes in the first Git-style sniff window of bounded data.
174+func IsBinary(contents []byte) bool {
175+	const sniffLength = 8000
176+	if len(contents) > sniffLength {
177+		contents = contents[:sniffLength]
178+	}
179+	return bytes.IndexByte(contents, 0) >= 0
180+}
181+
182+// Readme contains the bounded README data and its POSIX path.
183+type Readme struct {
184+	Path string
185+	BlobContent
186+}
187+
188+// LoadReadme searches root, .github, then docs for a regular README blob.
189+func LoadReadme(repo *git.Repository, ref *git.Reference) (*Readme, error) {
190+	for _, directory := range []string{"", ".github", "docs"} {
191+		tree, err := repo.TreePath(ref, directory)
192+		if err != nil {
193+			continue
194+		}
195+		entries, err := tree.Entries()
196+		if err != nil {
197+			continue
198+		}
199+		for _, entry := range entries {
200+			// IsBlob intentionally excludes executable blobs and symlinks. README
201+			// symlinks must not cause a second path lookup.
202+			if !entry.IsBlob() || !isReadmeName(entry.Name()) {
203+				continue
204+			}
205+			content, err := LoadBlob(entry, TextLimit)
206+			if err != nil {
207+				return nil, err
208+			}
209+			return &Readme{Path: path.Join(directory, entry.Name()), BlobContent: content}, nil
210+		}
211+	}
212+	return nil, ErrReadmeNotFound
213+}
214+
215+func isReadmeName(name string) bool {
216+	return strings.HasPrefix(strings.ToLower(name), "readme")
217+}
218diff --git a/pkg/web/pages/controller.go b/pkg/web/pages/controller.go
219index 08de5959f957947012717f8d7e8173de0eed24b0..b1f0f735abdacc9ea7b3183967fc3edaecf0b2c5 100644
220--- a/pkg/web/pages/controller.go
221+++ b/pkg/web/pages/controller.go
222@@ -27,6 +27,7 @@ func Controller(_ context.Context, r *mux.Router) {
223 
224 func home(w http.ResponseWriter, r *http.Request) {
225 	cfg := config.FromContext(r.Context())
226+	SetSecurityHeaders(w)
227 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
228 	if err := pageTemplates.ExecuteTemplate(w, "home", struct {
229 		ServerName string
230diff --git a/pkg/web/pages/primitives_test.go b/pkg/web/pages/primitives_test.go
231new file mode 100644
232index 0000000000000000000000000000000000000000..abdbf23653eec43bd37b6deb7d5c98528d8d7a67
233--- /dev/null
234+++ b/pkg/web/pages/primitives_test.go
235@@ -0,0 +1,183 @@
236+package pages
237+
238+import (
239+	"errors"
240+	"os"
241+	"os/exec"
242+	"path/filepath"
243+	"strings"
244+	"testing"
245+	"time"
246+
247+	"github.com/charmbracelet/soft-serve/git"
248+	"github.com/charmbracelet/soft-serve/pkg/access"
249+	"github.com/charmbracelet/soft-serve/pkg/proto"
250+)
251+
252+type testRepository struct {
253+	name    string
254+	private bool
255+	hidden  bool
256+	updated time.Time
257+}
258+
259+func (r testRepository) ID() int64                      { return 0 }
260+func (r testRepository) Name() string                   { return r.name }
261+func (r testRepository) ProjectName() string            { return "" }
262+func (r testRepository) Description() string            { return "" }
263+func (r testRepository) IsPrivate() bool                { return r.private }
264+func (r testRepository) IsMirror() bool                 { return false }
265+func (r testRepository) IsHidden() bool                 { return r.hidden }
266+func (r testRepository) UserID() int64                  { return 0 }
267+func (r testRepository) CreatedAt() time.Time           { return r.updated }
268+func (r testRepository) UpdatedAt() time.Time           { return r.updated }
269+func (r testRepository) Open() (*git.Repository, error) { return nil, errors.New("not used") }
270+
271+func TestIsPublicReadable(t *testing.T) {
272+	public := testRepository{name: "public"}
273+	private := testRepository{name: "private", private: true}
274+	if !IsPublicReadable(public, access.ReadOnlyAccess) {
275+		t.Fatal("public read-only repository is not readable")
276+	}
277+	if IsPublicReadable(private, access.AdminAccess) {
278+		t.Fatal("private repository must not be page-readable")
279+	}
280+	if IsPublicReadable(public, access.NoAccess) {
281+		t.Fatal("anonymous no-access repository is readable")
282+	}
283+}
284+
285+func TestFilterHomepageRepositories(t *testing.T) {
286+	newer := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
287+	input := []proto.Repository{
288+		testRepository{name: "zebra", updated: newer},
289+		testRepository{name: "alpha", updated: newer},
290+		testRepository{name: "older", updated: newer.Add(-time.Hour)},
291+		testRepository{name: "hidden", hidden: true, updated: newer},
292+		testRepository{name: ".soft-serve", updated: newer},
293+		testRepository{name: "unreadable", updated: newer},
294+	}
295+	visible := FilterHomepageRepositories(input, func(repo proto.Repository) bool {
296+		return repo.Name() != "unreadable"
297+	})
298+	got := make([]string, len(visible))
299+	for i, repo := range visible {
300+		got[i] = repo.Name()
301+	}
302+	want := []string{"alpha", "zebra", "older"}
303+	if strings.Join(got, ",") != strings.Join(want, ",") {
304+		t.Fatalf("visible repositories = %v, want %v", got, want)
305+	}
306+}
307+
308+func TestResolveRefAndValidation(t *testing.T) {
309+	repo := newTestRepository(t, map[string]string{"README.md": "hello"})
310+	runGit(t, repo.Path, "branch", "feature/with-slash")
311+
312+	ref, err := ResolveRef(repo, "refs/heads/feature/with-slash")
313+	if err != nil || ref.Name().String() != "refs/heads/feature/with-slash" {
314+		t.Fatalf("ResolveRef slash ref = %v, %v", ref, err)
315+	}
316+	if _, err := ResolveRef(repo, "feature/with-slash^{commit}"); !errors.Is(err, ErrInvalidRef) {
317+		t.Fatalf("ResolveRef arbitrary revision error = %v, want ErrInvalidRef", err)
318+	}
319+	for _, invalid := range []string{"", "abc", strings.Repeat("z", 40), strings.Repeat("a", 39)} {
320+		if invalid != "" && ValidCommitHash(invalid) {
321+			t.Fatalf("ValidCommitHash(%q) = true", invalid)
322+		}
323+	}
324+	if !ValidCommitHash(strings.Repeat("a", 40)) || !ValidCommitHash(strings.Repeat("B", 64)) {
325+		t.Fatal("valid complete object IDs rejected")
326+	}
327+	for _, invalid := range []string{"/etc", "../outside", "a/../../outside", "\x00name"} {
328+		if _, err := ValidateTreePath(invalid); !errors.Is(err, ErrInvalidTreePath) {
329+			t.Fatalf("ValidateTreePath(%q) error = %v", invalid, err)
330+		}
331+	}
332+	if got, err := ValidateTreePath("dir/../README.md"); err != nil || got != "README.md" {
333+		t.Fatalf("ValidateTreePath cleaned = %q, %v", got, err)
334+	}
335diff --git a/pkg/web/pages/raw.go b/pkg/web/pages/raw.go
336new file mode 100644
337index 0000000000000000000000000000000000000000..fb5110d32bb122260576476839046631b799cbc8
338--- /dev/null
339+++ b/pkg/web/pages/raw.go
340@@ -0,0 +1,44 @@
341+package pages
342+
343+import (
344+	"mime"
345+	"path"
346+	"strings"
347+)
348+
349+// RawMetadata describes how a raw blob may be sent without executable content.
350+type RawMetadata struct {
351+	ContentType        string
352+	ContentDisposition string
353+	Inline             bool
354+}
355+
356+var safeMediaTypes = map[string]string{
357+	".avif": "image/avif",
358+	".gif":  "image/gif",
359+	".jpeg": "image/jpeg",
360+	".jpg":  "image/jpeg",
361+	".png":  "image/png",
362+	".webp": "image/webp",
363+	".flac": "audio/flac",
364+	".m4a":  "audio/mp4",
365+	".mp3":  "audio/mpeg",
366+	".ogg":  "audio/ogg",
367+	".opus": "audio/opus",
368+	".wav":  "audio/wav",
369+	".m4v":  "video/mp4",
370+	".mp4":  "video/mp4",
371+	".ogv":  "video/ogg",
372+	".webm": "video/webm",
373+}
374+
375+// RawFileMetadata allows only known safe media types inline; all other files download.
376+func RawFileMetadata(filename string) RawMetadata {
377+	if mediaType, ok := safeMediaTypes[strings.ToLower(path.Ext(filename))]; ok {
378+		return RawMetadata{ContentType: mediaType, Inline: true}
379+	}
380+	return RawMetadata{
381+		ContentType:        "application/octet-stream",
382+		ContentDisposition: mime.FormatMediaType("attachment", map[string]string{"filename": path.Base(filename)}),
383+	}
384+}
385diff --git a/pkg/web/pages/render.go b/pkg/web/pages/render.go
386new file mode 100644
387index 0000000000000000000000000000000000000000..49fdbe3eca4310af356156e7485d408ab5b71ffb
388--- /dev/null
389+++ b/pkg/web/pages/render.go
390@@ -0,0 +1,55 @@
391+package pages
392+
393+import (
394+	"bytes"
395+	"html/template"
396+	"net/http"
397+
398+	"github.com/alecthomas/chroma/v2"
399+	chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
400+	"github.com/alecthomas/chroma/v2/lexers"
401+	"github.com/alecthomas/chroma/v2/styles"
402+	"github.com/microcosm-cc/bluemonday"
403+	"github.com/yuin/goldmark"
404+)
405+
406+// SetSecurityHeaders applies the common security policy for dynamic page responses.
407+func SetSecurityHeaders(w http.ResponseWriter) {
408+	w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
409+	w.Header().Set("X-Content-Type-Options", "nosniff")
410+	w.Header().Set("Referrer-Policy", "same-origin")
411+}
412+
413+// RenderMarkdown renders Markdown with raw HTML disabled and sanitizes the result.
414+func RenderMarkdown(source []byte) (template.HTML, error) {
415+	var rendered bytes.Buffer
416+	if err := goldmark.Convert(source, &rendered); err != nil {
417+		return "", err
418+	}
419+	return template.HTML(bluemonday.UGCPolicy().SanitizeBytes(rendered.Bytes())), nil // #nosec G203 -- sanitizer output only
420+}
421+
422+// RenderSource highlights bounded source using Chroma's escaping HTML formatter.
423+func RenderSource(filename string, source []byte) (template.HTML, error) {
424+	lexer := lexers.Match(filename)
425+	if lexer == nil {
426+		lexer = lexers.Analyse(string(source))
427+	}
428+	if lexer == nil {
429+		lexer = lexers.Fallback
430+	}
431+	iterator, err := lexer.Tokenise(nil, string(source))
432+	if err != nil {
433+		return "", err
434+	}
435+
436+	var rendered bytes.Buffer
437+	formatter := chromahtml.New(chromahtml.WithClasses(true))
438+	if err := formatter.Format(&rendered, styles.Fallback, iterator); err != nil {
439+		return "", err
440+	}
441+	return template.HTML(rendered.String()), nil // #nosec G203 -- Chroma escapes source tokens
442+}
443+
444+// Compile-time checks keep renderer dependencies explicit.
445+var _ chroma.Formatter = chromahtml.New()
446diff --git a/pkg/web/pages/repository.go b/pkg/web/pages/repository.go
447new file mode 100644
448index 0000000000000000000000000000000000000000..5bceaa286ee59ffcf1178dda75c2d473368404fe
449--- /dev/null
450+++ b/pkg/web/pages/repository.go
451@@ -0,0 +1,115 @@
452+package pages
453+
454+import (
455+	"errors"
456+	"fmt"
457+	"path"
458+	"strings"
459+
460+	"github.com/charmbracelet/soft-serve/git"
461+)
462+
463+const maxTreeEntries = 1000
464+
465+var (
466+	// ErrInvalidRef is returned for a requested ref that is not an actual ref.
467+	ErrInvalidRef = errors.New("invalid reference")
468+	// ErrInvalidCommitHash is returned before looking up a malformed object ID.
469+	ErrInvalidCommitHash = errors.New("invalid commit hash")
470+	// ErrInvalidTreePath is returned when a path is not contained in a Git tree.
471+	ErrInvalidTreePath = errors.New("invalid tree path")
472+)
473+
474+// ResolveRef resolves an empty ref to HEAD or matches a full ref name exactly.
475+func ResolveRef(repo *git.Repository, requested string) (*git.Reference, error) {
476+	if requested == "" {
477+		return repo.HEAD()
478+	}
479+
480+	refs, err := repo.References()
481+	if err != nil {
482+		return nil, err
483+	}
484+	for _, ref := range refs {
485+		if ref.Name().String() == requested {
486+			return ref, nil
487+		}
488+	}
489+	return nil, ErrInvalidRef
490+}
491+
492+// ValidCommitHash accepts only complete SHA-1 or SHA-256 hexadecimal object IDs.
493+func ValidCommitHash(hash string) bool {
494+	if len(hash) != 40 && len(hash) != 64 {
495+		return false
496+	}
497+	for _, char := range hash {
498+		if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
499+			return false
500+		}
501+	}
502+	return true
503+}
504+
505+// LookupCommit validates hash before asking Git to load the commit object.
506+func LookupCommit(repo *git.Repository, hash string) (*git.Commit, error) {
507+	if !ValidCommitHash(hash) {
508+		return nil, ErrInvalidCommitHash
509+	}
510+	return repo.CatFileCommit(hash)
511+}
512+
513+// ValidateTreePath converts a POSIX tree path to its canonical relative form.
514+func ValidateTreePath(treePath string) (string, error) {
515+	if treePath == "" || treePath == "." {
516+		return "", nil
517+	}
518+	if strings.ContainsRune(treePath, 0) || path.IsAbs(treePath) {
519+		return "", ErrInvalidTreePath
520+	}
521+	cleaned := path.Clean(treePath)
522+	if cleaned == ".." || strings.HasPrefix(cleaned, "../") {
523+		return "", ErrInvalidTreePath
524+	}
525+	if cleaned == "." {
526+		return "", nil
527+	}
528+	return cleaned, nil
529+}
530+
531+// TreeEntries returns at most 1,000 entries from a validated tree path.
532+func TreeEntries(repo *git.Repository, ref *git.Reference, treePath string) (git.Entries, bool, error) {
533+	cleaned, err := ValidateTreePath(treePath)
534+	if err != nil {
535+		return nil, false, err
536+	}
537+	tree, err := repo.TreePath(ref, cleaned)
538+	if err != nil {
539+		return nil, false, err
540+	}
541+	entries, err := tree.Entries()
542+	if err != nil {
543+		return nil, false, err
544+	}
545+	entries.Sort()
546+	if len(entries) > maxTreeEntries {
547+		return entries[:maxTreeEntries], true, nil
548+	}
549+	return entries, false, nil
550+}
551diff --git a/pkg/web/pages/urls.go b/pkg/web/pages/urls.go
552new file mode 100644
553index 0000000000000000000000000000000000000000..7a86e7b49da50b77998a0e1747bd06adac4a3e1d
554--- /dev/null
555+++ b/pkg/web/pages/urls.go
556@@ -0,0 +1,46 @@
557+package pages
558+
559+import "net/url"
560+
561+// PageURL builds a page operation URL without interpolating untrusted values.
562+func PageURL(repository, operation string, query url.Values) string {
563+	segments := append(splitRepositoryName(repository), "@", operation)
564+	u := (&url.URL{Path: "/"}).JoinPath(segments...)
565+	if query != nil {
566+		u.RawQuery = query.Encode()
567+	}
568+	return u.String()
569+}
570+
571+// TreeURL builds a tree-browser URL for a selected repository, ref, and path.
572+func TreeURL(repository, ref, treePath string) string {
573+	return PageURL(repository, "tree", url.Values{"ref": {ref}, "path": {treePath}})
574+}
575+
576+// RawURL builds a raw-file URL for a selected repository, ref, and path.
577+func RawURL(repository, ref, treePath string) string {
578+	return PageURL(repository, "raw", url.Values{"ref": {ref}, "path": {treePath}})
579+}
580+
581+func splitRepositoryName(name string) []string {
582+	// A repository name may be nested. URL.JoinPath escapes each component and
583+	// preserves only the deliberate repository separators.
584+	return splitNonEmpty(name)
585+}
586+
587+func splitNonEmpty(value string) []string {
588+	segments := make([]string, 0, 1)
589+	start := 0
590+	for i := range value {
591+		if value[i] == '/' {
592+			if i > start {
593+				segments = append(segments, value[start:i])
594+			}
595+			start = i + 1
596+		}
597+	}
598+	if start < len(value) {
599+		segments = append(segments, value[start:])
600+	}
601+	return segments
602+}