Go · 19157 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package repo
4
5 import (
6 "bytes"
7 "context"
8 "errors"
9 "fmt"
10 "html/template"
11 "net/http"
12 "path"
13 "strings"
14
15 "github.com/go-chi/chi/v5"
16
17 "github.com/tenseleyFlow/shithub/internal/auth/policy"
18 mdrender "github.com/tenseleyFlow/shithub/internal/markdown"
19 "github.com/tenseleyFlow/shithub/internal/repos/finder"
20 repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
21 "github.com/tenseleyFlow/shithub/internal/repos/highlight"
22 "github.com/tenseleyFlow/shithub/internal/repos/identity"
23 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
24 "github.com/tenseleyFlow/shithub/internal/web/middleware"
25 )
26
27 // MountCode registers the code-tab routes:
28 //
29 // GET /{owner}/{repo}/tree/*
30 // GET /{owner}/{repo}/blob/*
31 // GET /{owner}/{repo}/raw/*
32 // GET /{owner}/{repo}/find/*
33 // GET/POST /{owner}/{repo}/edit/*
34 // GET/POST /{owner}/{repo}/new/*
35 // GET/POST /{owner}/{repo}/delete/*
36 // GET/POST /{owner}/{repo}/upload/*
37 //
38 // The leading {ref} segment is variable-length (refs may contain `/`).
39 // chi's `*` wildcard captures the rest; we resolve ref + path inside
40 // the handler against the repo's known ref list.
41 func (h *Handlers) MountCode(r chi.Router) {
42 r.Post("/{owner}/{repo}/markdown-preview", h.codeMarkdownPreview)
43 r.Get("/{owner}/{repo}/edit/*", h.codeEditForm)
44 r.Post("/{owner}/{repo}/edit/*", h.codeEditSubmit)
45 r.Get("/{owner}/{repo}/new/*", h.codeNewForm)
46 r.Post("/{owner}/{repo}/new/*", h.codeNewSubmit)
47 r.Get("/{owner}/{repo}/delete/*", h.codeDeleteForm)
48 r.Post("/{owner}/{repo}/delete/*", h.codeDeleteSubmit)
49 r.Get("/{owner}/{repo}/upload/*", h.codeUploadForm)
50 r.Post("/{owner}/{repo}/upload/*", h.codeUploadSubmit)
51 r.Get("/{owner}/{repo}/tree/*", h.codeTree)
52 r.Get("/{owner}/{repo}/blob/*", h.codeBlob)
53 r.Get("/{owner}/{repo}/raw/*", h.codeRaw)
54 r.Get("/{owner}/{repo}/find/*", h.codeFinder)
55 }
56
57 // codeContext bundles the per-request data the code-tab handlers
58 // derive once at the top. Owner+repo come from chi; ref+path come from
59 // the wildcard, resolved against the repo's ref list.
60 type codeContext struct {
61 owner string
62 row reposdb.Repo
63 gitDir string
64 refs repogit.RefListing
65 allRefs []string
66 ref string // matched ref name (or 40-hex sha)
67 subpath string // path inside the ref, no leading slash
68 }
69
70 type codeBranchCompare struct {
71 Show bool
72 HasRecentPush bool
73 Base string
74 Head string
75 Ahead int
76 Behind int
77 CompareHref string
78 PullNewHref string
79 }
80
81 // loadCodeContext does the resolve dance for tree/blob/raw/find. On
82 // any failure it writes the response and returns ok=false.
83 func (h *Handlers) loadCodeContext(w http.ResponseWriter, r *http.Request) (*codeContext, bool) {
84 return h.loadCodeContextFor(w, r, policy.ActionRepoRead)
85 }
86
87 func (h *Handlers) loadCodeContextFor(w http.ResponseWriter, r *http.Request, action policy.Action) (*codeContext, bool) {
88 row, owner, ok := h.loadRepoAndAuthorize(w, r, action)
89 if !ok {
90 return nil, false
91 }
92 gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
93 if err != nil {
94 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
95 return nil, false
96 }
97 refs, err := repogit.ListRefs(r.Context(), gitDir)
98 if err != nil {
99 h.d.Logger.WarnContext(r.Context(), "code: ListRefs", "error", err)
100 }
101 allNames := refNames(refs)
102
103 rest := chi.URLParam(r, "*")
104 rest = strings.Trim(rest, "/")
105 segs := []string{}
106 if rest != "" {
107 segs = strings.Split(rest, "/")
108 }
109 if len(segs) == 0 {
110 // no ref → default branch root tree
111 ref := row.DefaultBranch
112 return &codeContext{
113 owner: owner.Username, row: row, gitDir: gitDir,
114 refs: refs, allRefs: allNames, ref: ref, subpath: "",
115 }, true
116 }
117 ref, sub, ok2 := repogit.ResolveRef(allNames, segs)
118 if !ok2 {
119 // Fallback: if the first segment looks like a hex sha, accept it.
120 if len(segs[0]) == 40 && isHex(segs[0]) {
121 ref = segs[0]
122 sub = strings.Join(segs[1:], "/")
123 } else {
124 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
125 return nil, false
126 }
127 }
128 if !validateSubpath(sub) {
129 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
130 return nil, false
131 }
132 return &codeContext{
133 owner: owner.Username, row: row, gitDir: gitDir,
134 refs: refs, allRefs: allNames, ref: ref, subpath: sub,
135 }, true
136 }
137
138 func (cc *codeContext) isBranchRef() bool {
139 for _, b := range cc.refs.Branches {
140 if b.Name == cc.ref {
141 return true
142 }
143 }
144 return false
145 }
146
147 func codeBranchCompareData(ctx context.Context, cc *codeContext) codeBranchCompare {
148 if !cc.isBranchRef() || cc.ref == cc.row.DefaultBranch {
149 return codeBranchCompare{}
150 }
151 ahead, behind, err := repogit.AheadBehind(ctx, cc.gitDir, cc.row.DefaultBranch, cc.ref)
152 if err != nil {
153 return codeBranchCompare{}
154 }
155 return codeBranchCompare{
156 Show: true,
157 HasRecentPush: ahead > 0,
158 Base: cc.row.DefaultBranch,
159 Head: cc.ref,
160 Ahead: ahead,
161 Behind: behind,
162 CompareHref: compareURL(cc.owner, cc.row.Name, cc.row.DefaultBranch, cc.ref),
163 PullNewHref: pullNewURL(cc.owner, cc.row.Name, cc.row.DefaultBranch, cc.ref),
164 }
165 }
166
167 func (h *Handlers) canWriteRepo(r *http.Request, row reposdb.Repo) bool {
168 viewer := middleware.CurrentUserFromContext(r.Context())
169 if viewer.IsAnonymous() {
170 return false
171 }
172 dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, viewer.PolicyActor(), policy.ActionRepoWrite, policy.NewRepoRefFromRepo(row))
173 return dec.Allow
174 }
175
176 // codeTree renders the directory listing at <ref>:<subpath>. If the
177 // path turns out to be a blob, redirects to /blob/. README rendering
178 // for tree-roots is appended below the listing.
179 func (h *Handlers) codeTree(w http.ResponseWriter, r *http.Request) {
180 cc, ok := h.loadCodeContext(w, r)
181 if !ok {
182 return
183 }
184 h.renderRepoTree(w, r, cc)
185 }
186
187 func (h *Handlers) renderRepoTree(w http.ResponseWriter, r *http.Request, cc *codeContext) {
188 kind, _, _, err := repogit.StatPath(r.Context(), cc.gitDir, cc.ref, cc.subpath)
189 if err != nil {
190 if errors.Is(err, repogit.ErrPathNotFound) {
191 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
192 return
193 }
194 h.d.Logger.WarnContext(r.Context(), "code: StatPath", "error", err)
195 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
196 return
197 }
198 if kind == repogit.EntryBlob {
199 http.Redirect(w, r, "/"+cc.owner+"/"+cc.row.Name+"/blob/"+cc.ref+"/"+cc.subpath, http.StatusSeeOther)
200 return
201 }
202 entries, err := repogit.LsTree(r.Context(), cc.gitDir, cc.ref, cc.subpath)
203 if err != nil {
204 if errors.Is(err, repogit.ErrNotATree) {
205 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
206 return
207 }
208 h.d.Logger.WarnContext(r.Context(), "code: LsTree", "error", err)
209 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
210 return
211 }
212 // README detection on the requested directory only.
213 readme := h.findAndRenderREADME(r, cc, entries)
214 canWrite := h.canWriteRepo(r, cc.row) && cc.isBranchRef()
215 head, headFound, headErr := repogit.CommitAt(r.Context(), cc.gitDir, cc.ref)
216 if headErr != nil {
217 h.d.Logger.WarnContext(r.Context(), "code: HeadOf", "error", headErr)
218 }
219 headAuthor := identity.Resolved{}
220 if headFound {
221 headAuthor = identity.New(h.d.Pool).Resolve(r.Context(), head.AuthorEmail)
222 }
223 headCheckSummary := codeCommitCheckSummary{}
224 if headFound {
225 headCheckSummary = h.codeCommitCheckSummary(r.Context(), cc.owner, cc.row.Name, cc.row.ID, head.OID)
226 }
227 commitCount, countErr := repogit.CountCommits(r.Context(), cc.gitDir, cc.ref)
228 if countErr != nil {
229 h.d.Logger.WarnContext(r.Context(), "code: CountCommits", "error", countErr)
230 }
231 topics, _ := h.rq.ListRepoTopics(r.Context(), h.d.Pool, cc.row.ID)
232 aboutEntries := entries
233 if cc.subpath != "" {
234 if rootEntries, rerr := repogit.LsTree(r.Context(), cc.gitDir, cc.ref, ""); rerr == nil {
235 aboutEntries = rootEntries
236 } else {
237 h.d.Logger.WarnContext(r.Context(), "code: about root LsTree", "error", rerr)
238 }
239 }
240 about := h.repoAbout(r.Context(), cc.gitDir, cc.ref, cc.owner, cc.row, aboutEntries)
241
242 h.d.Render.RenderPage(w, r, "repo/tree", map[string]any{
243 "Title": cc.row.Name + " · " + cc.owner,
244 "CSRFToken": middleware.CSRFTokenForRequest(r),
245 "Owner": cc.owner,
246 "Repo": cc.row,
247 "Ref": cc.ref,
248 "RefDisplay": codeRefDisplay(cc.ref),
249 "Path": cc.subpath,
250 "Crumbs": breadcrumbs(cc.owner, cc.row.Name, cc.ref, cc.subpath),
251 "Entries": entries,
252 "EntryRows": h.codeTreeEntryRows(r.Context(), cc, entries),
253 "Branches": cc.refs.Branches,
254 "Tags": cc.refs.Tags,
255 "Head": head,
256 "HeadFound": headFound,
257 "HeadAuthor": headAuthor,
258 "HeadChecks": headCheckSummary,
259 "BranchCompare": codeBranchCompareData(r.Context(), cc),
260 "CommitCount": commitCount,
261 "README": template.HTML(readme.HTML), //nolint:gosec // sanitized by mdrender
262 "READMEPath": readme.Path,
263 "HTTPSCloneURL": h.cloneHTTPS(cc.owner, cc.row.Name),
264 "SSHEnabled": h.d.CloneURLs.SSHEnabled,
265 "SSHCloneURL": h.cloneSSH(cc.owner, cc.row.Name),
266 "RepoTopics": topics,
267 "RepoAbout": about,
268 "ReadmeTabs": repoReadmeTabs(about.Resources),
269 "RepoActions": h.repoActions(r, cc.row.ID),
270 "RepoCounts": h.subnavCounts(r.Context(), cc.row.ID, cc.row.ForkCount),
271 "CanWrite": canWrite,
272 "CanSettings": h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
273 "ActiveSubnav": "code",
274 })
275 }
276
277 func refNames(refs repogit.RefListing) []string {
278 allNames := make([]string, 0, len(refs.Branches)+len(refs.Tags))
279 for _, b := range refs.Branches {
280 allNames = append(allNames, b.Name)
281 }
282 for _, t := range refs.Tags {
283 allNames = append(allNames, t.Name)
284 }
285 return allNames
286 }
287
288 func codeRefDisplay(ref string) string {
289 if len(ref) == 40 && isHex(ref) {
290 return ref[:7]
291 }
292 return ref
293 }
294
295 type readmeRender struct {
296 HTML string
297 Path string
298 }
299
300 // findAndRenderREADME looks for README* in the supplied entries (case-
301 // insensitive). Returns rendered HTML for markdown sources; returns a
302 // `<pre>`-wrapped escaped string for non-markdown text. Empty when
303 // no README is present.
304 func (h *Handlers) findAndRenderREADME(r *http.Request, cc *codeContext, entries []repogit.TreeEntry) readmeRender {
305 const maxREADMEBytes = 1 * 1024 * 1024 // 1 MiB cap
306 for _, e := range entries {
307 if e.Kind != repogit.EntryBlob {
308 continue
309 }
310 lower := strings.ToLower(e.Name)
311 if !strings.HasPrefix(lower, "readme") {
312 continue
313 }
314 full := joinPath(cc.subpath, e.Name)
315 body, err := repogit.ReadBlobBytes(r.Context(), cc.gitDir, cc.ref, full, maxREADMEBytes)
316 if err != nil && !errors.Is(err, repogit.ErrBlobTooLarge) {
317 return readmeRender{}
318 }
319 // Markdown: render via Goldmark + sanitizer.
320 if hasExt(lower, []string{".md", ".markdown"}) {
321 out, mderr := mdrender.RenderDocumentHTML(body)
322 if mderr == nil {
323 return readmeRender{Path: full, HTML: rewriteMarkdownRelativeURLs(
324 out,
325 codeRouteBase(cc.owner, cc.row.Name, "blob", cc.ref, cc.subpath),
326 codeRouteBase(cc.owner, cc.row.Name, "blob", cc.ref, ""),
327 codeRouteBase(cc.owner, cc.row.Name, "raw", cc.ref, cc.subpath),
328 codeRouteBase(cc.owner, cc.row.Name, "raw", cc.ref, ""),
329 )}
330 }
331 }
332 // Non-markdown plain text: escape + <pre>.
333 return readmeRender{Path: full, HTML: "<pre class=\"shithub-readme-plain\">" + template.HTMLEscapeString(string(body)) + "</pre>"}
334 }
335 return readmeRender{}
336 }
337
338 func hasExt(filename string, exts []string) bool {
339 for _, e := range exts {
340 if strings.HasSuffix(filename, e) {
341 return true
342 }
343 }
344 return false
345 }
346
347 // codeBlob renders the file viewer.
348 func (h *Handlers) codeBlob(w http.ResponseWriter, r *http.Request) {
349 cc, ok := h.loadCodeContext(w, r)
350 if !ok {
351 return
352 }
353 kind, _, size, err := repogit.StatPath(r.Context(), cc.gitDir, cc.ref, cc.subpath)
354 if err != nil || kind != repogit.EntryBlob {
355 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
356 return
357 }
358 const largeFileThreshold = 1 * 1024 * 1024 // 1 MiB
359 const maxReadBytes = 4 * 1024 * 1024 // never read more than 4 MiB even for highlighting
360
361 data := map[string]any{
362 "Title": cc.subpath + " · " + cc.row.Name,
363 "CSRFToken": middleware.CSRFTokenForRequest(r),
364 "Owner": cc.owner,
365 "Repo": cc.row,
366 "Ref": cc.ref,
367 "Path": cc.subpath,
368 "Crumbs": breadcrumbs(cc.owner, cc.row.Name, cc.ref, cc.subpath),
369 "Branches": cc.refs.Branches,
370 "Tags": cc.refs.Tags,
371 "Size": size,
372 "IsLarge": size > largeFileThreshold,
373 "IsBinary": false,
374 "IsImage": false,
375 "IsMarkdown": false,
376 "Language": highlight.LanguageGuess(cc.subpath),
377 "RepoCounts": h.subnavCounts(r.Context(), cc.row.ID, cc.row.ForkCount),
378 "CanWrite": h.canWriteRepo(r, cc.row) && cc.isBranchRef(),
379 "CanSettings": h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
380 "ActiveSubnav": "code",
381 }
382 if size > largeFileThreshold {
383 h.d.Render.RenderPage(w, r, "repo/blob", data)
384 return
385 }
386 body, err := repogit.ReadBlobBytes(r.Context(), cc.gitDir, cc.ref, cc.subpath, maxReadBytes)
387 if err != nil && !errors.Is(err, repogit.ErrBlobTooLarge) {
388 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
389 return
390 }
391 if isBinary(body) {
392 data["IsBinary"] = true
393 if isImageExt(cc.subpath) && size <= 5*1024*1024 {
394 data["IsImage"] = true
395 }
396 h.d.Render.RenderPage(w, r, "repo/blob", data)
397 return
398 }
399 // Text path: highlight or markdown-render.
400 if hasExt(strings.ToLower(cc.subpath), []string{".md", ".markdown"}) {
401 data["IsMarkdown"] = true
402 rendered, mderr := mdrender.RenderDocumentHTML(body)
403 if mderr == nil {
404 dir := path.Dir(cc.subpath)
405 if dir == "." {
406 dir = ""
407 }
408 rendered = rewriteMarkdownRelativeURLs(
409 rendered,
410 codeRouteBase(cc.owner, cc.row.Name, "blob", cc.ref, dir),
411 codeRouteBase(cc.owner, cc.row.Name, "blob", cc.ref, ""),
412 codeRouteBase(cc.owner, cc.row.Name, "raw", cc.ref, dir),
413 codeRouteBase(cc.owner, cc.row.Name, "raw", cc.ref, ""),
414 )
415 data["MarkdownHTML"] = template.HTML(rendered) //nolint:gosec // sanitized
416 }
417 data["RawSource"] = string(body)
418 }
419 // Per-line highlighted fragments. The template composes the row
420 // table; chroma only colors the tokens inside each line.
421 data["Lines"] = highlight.RenderLines(cc.subpath, string(body))
422 h.d.Render.RenderPage(w, r, "repo/blob", data)
423 }
424
425 // codeRaw streams the raw bytes. Force `attachment` for executable
426 // content types (HTML/SVG/JS/etc.) since shithub doesn't have a
427 // separate raw host.
428 func (h *Handlers) codeRaw(w http.ResponseWriter, r *http.Request) {
429 cc, ok := h.loadCodeContext(w, r)
430 if !ok {
431 return
432 }
433 kind, _, size, err := repogit.StatPath(r.Context(), cc.gitDir, cc.ref, cc.subpath)
434 if err != nil || kind != repogit.EntryBlob {
435 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
436 return
437 }
438 contentType, forceAttachment := rawContentType(cc.subpath)
439 w.Header().Set("Content-Type", contentType)
440 w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
441 w.Header().Set("X-Content-Type-Options", "nosniff")
442 w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
443 if forceAttachment {
444 w.Header().Set("Content-Disposition", `attachment; filename="`+path.Base(cc.subpath)+`"`)
445 }
446 if size > 0 {
447 w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
448 }
449 if err := repogit.StreamBlob(r.Context(), cc.gitDir, cc.ref, cc.subpath, w); err != nil {
450 h.d.Logger.WarnContext(r.Context(), "code: stream raw", "error", err)
451 }
452 }
453
454 // codeFinder serves /find/{ref} — full list pre-filtered by `q`.
455 func (h *Handlers) codeFinder(w http.ResponseWriter, r *http.Request) {
456 cc, ok := h.loadCodeContext(w, r)
457 if !ok {
458 return
459 }
460 paths, err := repogit.ListAllPaths(r.Context(), cc.gitDir, cc.ref)
461 if err != nil {
462 h.d.Logger.WarnContext(r.Context(), "code: ListAllPaths", "error", err)
463 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
464 return
465 }
466 q := r.URL.Query().Get("q")
467 matches := finder.Filter(paths, q, 200)
468 h.d.Render.RenderPage(w, r, "repo/finder", map[string]any{
469 "Title": "Find file · " + cc.row.Name,
470 "CSRFToken": middleware.CSRFTokenForRequest(r),
471 "Owner": cc.owner,
472 "Repo": cc.row,
473 "Ref": cc.ref,
474 "Query": q,
475 "Matches": matches,
476 "Branches": cc.refs.Branches,
477 "Tags": cc.refs.Tags,
478 })
479 }
480
481 // breadcrumbs returns the click-each-segment slice for the tree/blob
482 // header.
483 type Breadcrumb struct {
484 Name string
485 URL string
486 }
487
488 func breadcrumbs(owner, repoName, ref, subpath string) []Breadcrumb {
489 out := []Breadcrumb{
490 {Name: repoName, URL: fmt.Sprintf("/%s/%s/tree/%s", owner, repoName, ref)},
491 }
492 if subpath == "" {
493 return out
494 }
495 parts := strings.Split(subpath, "/")
496 for i, p := range parts {
497 out = append(out, Breadcrumb{
498 Name: p,
499 URL: fmt.Sprintf("/%s/%s/tree/%s/%s", owner, repoName, ref, strings.Join(parts[:i+1], "/")),
500 })
501 }
502 return out
503 }
504
505 // validateSubpath is the path-traversal guard. Reject `..`, control
506 // chars, leading slash, and `\`.
507 func validateSubpath(p string) bool {
508 if p == "" {
509 return true
510 }
511 if strings.HasPrefix(p, "/") || strings.Contains(p, "\\") {
512 return false
513 }
514 for _, seg := range strings.Split(p, "/") {
515 if seg == "" || seg == ".." {
516 return false
517 }
518 for _, c := range seg {
519 if c < 0x20 || c == 0x7f {
520 return false
521 }
522 }
523 }
524 return true
525 }
526
527 func isHex(s string) bool {
528 for _, c := range s {
529 switch {
530 case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
531 default:
532 return false
533 }
534 }
535 return true
536 }
537
538 // rawContentType maps an extension to (Content-Type, forceAttachment).
539 // Executable content types force `attachment` to defeat XSS via raw view
540 // (no separate raw.host yet).
541 func rawContentType(p string) (string, bool) {
542 ext := strings.ToLower(path.Ext(p))
543 switch ext {
544 case ".html", ".htm", ".xhtml", ".js", ".mjs", ".wasm":
545 return "text/plain; charset=utf-8", true
546 case ".svg":
547 return "image/svg+xml", false
548 case ".png":
549 return "image/png", false
550 case ".jpg", ".jpeg":
551 return "image/jpeg", false
552 case ".gif":
553 return "image/gif", false
554 case ".webp":
555 return "image/webp", false
556 case ".pdf":
557 return "application/pdf", false
558 case ".css":
559 return "text/css; charset=utf-8", false
560 case ".json":
561 return "application/json; charset=utf-8", false
562 case ".txt", ".md", ".markdown", ".yml", ".yaml", ".toml", ".log":
563 return "text/plain; charset=utf-8", false
564 default:
565 // Sniff by inspecting the body would be ideal, but we already
566 // stream — fall back to text/plain for safety.
567 return "text/plain; charset=utf-8", false
568 }
569 }
570
571 func isImageExt(p string) bool {
572 switch strings.ToLower(path.Ext(p)) {
573 case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
574 return true
575 }
576 return false
577 }
578
579 // isBinary scans the first 8 KiB for a NUL byte.
580 func isBinary(b []byte) bool {
581 const window = 8192
582 if len(b) > window {
583 b = b[:window]
584 }
585 return bytes.IndexByte(b, 0) >= 0
586 }
587
588 // joinPath joins two slash-separated paths, ignoring an empty parent.
589 func joinPath(parent, child string) string {
590 if parent == "" {
591 return child
592 }
593 return parent + "/" + child
594 }
595