Download
169 lines 4.5 KiBGoModified 2026-10-27 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
// Package httpd matches request paths the way OpenBSD's httpd(8) matches
// the locations of its httpd.conf(5), so that the tests can check
// examples/httpd.conf against a rendered site.
package httpd

import (
	"fmt"
	"strconv"
	"strings"
)

// A Location is what follows "location" in httpd.conf(5): a GlobLocation
// or a PatternLocation.
type Location interface {
	// Match reports whether a request for p takes this location, and its
	// captures, %0 (the whole match) then %1 and on, for a rewrite.
	Match(p string) (caps []string, ok bool)
	// Check reports why httpd would refuse the location, or why Match
	// would not match as httpd does. A location that fails it matches
	// nothing.
	Check() error
	// String is the location as httpd.conf writes it, after "location".
	String() string
}

// GlobLocation is `location "<glob>"`. httpd matches it with fnmatch(3),
// FNM_CASEFOLD and no FNM_PATHNAME, so case is ignored and * also matches
// /. Only * is supported: Check refuses ?, [ and \, which mean more to
// fnmatch than they would here.
type GlobLocation string

func (g GlobLocation) Match(p string) ([]string, bool) {
	if g.Check() != nil {
		return nil, false
	}
	return nil, globMatch(lowerASCII(string(g)), lowerASCII(p))
}

func (g GlobLocation) Check() error {
	if i := strings.IndexAny(string(g), `?[\`); i >= 0 {
		return fmt.Errorf("glob %q: %q is not supported, only *", string(g), g[i])
	}
	return checkQuotable(string(g))
}

func (g GlobLocation) String() string { return `"` + string(g) + `"` }

// PatternLocation is `location match "<pattern>"`: a Lua pattern, as
// patterns(7) describes it, found anywhere in the path unless anchored
// with ^. Case matters. Check refuses %b, %f, back references and
// position captures, which Match does not support.
type PatternLocation string

func (l PatternLocation) Match(p string) ([]string, bool) {
	if l.Check() != nil {
		return nil, false
	}
	return find(p, string(l))
}

func (l PatternLocation) Check() error {
	if _, err := checkPattern(string(l)); err != nil {
		return fmt.Errorf("pattern %q: %w", string(l), err)
	}
	return checkQuotable(string(l))
}

func (l PatternLocation) String() string { return `match "` + string(l) + `"` }

// checkQuotable refuses what a quoted httpd.conf string cannot hold as it
// is: quotes, backslashes and control characters.
func checkQuotable(s string) error {
	for _, c := range []byte(s) {
		if c == '"' || c == '\\' || c < ' ' || c == 0x7f {
			return fmt.Errorf("%q cannot be written in httpd.conf", s)
		}
	}
	return nil
}

// Rewrite is the path a `request rewrite` to target sends a request to,
// with each %n replaced by capture n of the location that took it. A %n
// past the captures is left as it is.
func Rewrite(target string, caps []string) string {
	return expand(target, func(n int, ref string) string {
		if n < len(caps) {
			return caps[n]
		}
		return ref
	})
}

// CheckRewrite reports why target cannot be a rewrite for loc: a glob has
// no captures, and a pattern has %0, the whole match, and one for each of
// its parentheses.
func CheckRewrite(loc Location, target string) error {
	caps := 0
	if l, ok := loc.(PatternLocation); ok {
		n, err := checkPattern(string(l))
		if err != nil {
			return err
		}
		caps = 1 + n
	}
	var err error
	expand(target, func(n int, ref string) string {
		if n >= caps && err == nil {
			err = fmt.Errorf("rewrite %q: location %s has no %s", target, loc, ref)
		}
		return ref
	})
	if err != nil {
		return err
	}
	return checkQuotable(target)
}

// expand replaces each %n in s, a % and digits, with f(n, "%n").
func expand(s string, f func(n int, ref string) string) string {
	var b strings.Builder
	for {
		i := strings.IndexByte(s, '%')
		if i < 0 {
			break
		}
		j := i + 1
		for j < len(s) && '0' <= s[j] && s[j] <= '9' {
			j++
		}
		b.WriteString(s[:i])
		if n, err := strconv.Atoi(s[i+1 : j]); err == nil {
			b.WriteString(f(n, s[i:j]))
		} else {
			b.WriteString(s[i:j])
		}
		s = s[j:]
	}
	b.WriteString(s)
	return b.String()
}

// globMatch is fnmatch(3) without FNM_PATHNAME for a pattern of literal
// text and *.
func globMatch(pattern, s string) bool {
	star := strings.IndexByte(pattern, '*')
	if star < 0 {
		return pattern == s
	}
	if !strings.HasPrefix(s, pattern[:star]) {
		return false
	}
	for i := star; i <= len(s); i++ {
		if globMatch(pattern[star+1:], s[i:]) {
			return true
		}
	}
	return false
}

// lowerASCII folds case as FNM_CASEFOLD does in the C locale.
func lowerASCII(s string) string {
	b := []byte(s)
	for i, c := range b {
		if 'A' <= c && c <= 'Z' {
			b[i] = c + 'a' - 'A'
		}
	}
	return string(b)
}