A domain name in the RDATA section written without a trailing dot is relative to the zone origin (RFC 1035 §5.1), so "www CNAME host" in example.com. means host.example.com. The qualifier ran on the record after dns.NewRR had already resolved every RDATA name against the root, which turns "host" into "host." and makes the two indistinguishable -- relative() saw the trailing dot and left it alone. Every relative CNAME, NS, MX, PTR, SRV, NAPTR, SOA and SVCB target therefore pointed at the DNS root, which showed up as a local zone answering a CNAME with "host." and the alias target holding no local answer. Qualification now happens in assemble on the token stream, before the record is built, using a table of the RDATA positions that hold a name per type; names that already end in a dot stay absolute, so the RPZ targets "." and "rpz-drop." and the null MX targets are unaffected. Fix three wrong expectations in the localzone tests while here. The loop fixture was built with dns.NewRR on "alias.example.com", which cannot parse because the RR library wants a TTL and class column, so the expected record now spells them out. The origin-derivation fixture used "a" as an owner name, and a leading token that names an RR type reads as an omitted owner, which has no previous owner to inherit on the first line of a file; the owner is now a word. The merge test counted the SOA, but it is zone metadata rather than a queryable record, so two files contribute three records. The holder race test also looked up a name from the base zone while the holder was serving a store compiled from other.test., which no answer could satisfy; it now accepts either store's data.
592 lines
16 KiB
Go
592 lines
16 KiB
Go
// Package zonefile parses RFC 1035 master files ("zone files") with the
|
|
// $TTL/$ORIGIN directives, parenthesised continuations and RFC 1982/1996
|
|
// serial arithmetic.
|
|
//
|
|
// fancy-dns uses master files for two things: locally defined authoritative
|
|
// data, and RPZ-style response-policy zones that double as a blocklist source.
|
|
// Sharing one parser keeps operator expectations ("it is just a zone file")
|
|
// true for both.
|
|
package zonefile
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
// Record is one parsed resource record with its origin resolved.
|
|
type Record struct {
|
|
// Name is the fully-qualified, lowercased owner name.
|
|
Name string
|
|
TTL uint32
|
|
RR dns.RR
|
|
// Line is the 1-based source line, for error reporting.
|
|
Line int
|
|
// File is the source path, for error reporting.
|
|
File string
|
|
}
|
|
|
|
// Text renders the record back into zone-file syntax.
|
|
func (r Record) Text() string { return r.RR.String() }
|
|
|
|
// Zone is the result of parsing a master file.
|
|
type Zone struct {
|
|
// Origin is the zone's apex as configured.
|
|
Origin string
|
|
// Records are in file order.
|
|
Records []Record
|
|
// File is the path parsed, or "" for reader input.
|
|
File string
|
|
// MinTTL is the smallest explicit TTL seen.
|
|
MinTTL uint32
|
|
// SOA holds the zone's start-of-authority record, if present.
|
|
SOA *dns.SOA
|
|
// DefaultTTL is the $TTL directive value, else the SOA minimum.
|
|
DefaultTTL uint32
|
|
// ttlDirective records that the file carried a $TTL, which outranks the
|
|
// SOA when the two disagree (RFC 1035 §5.2).
|
|
ttlDirective bool
|
|
}
|
|
|
|
// Options tunes parsing.
|
|
type Options struct {
|
|
// Origin overrides $ORIGIN and is the default when a file has none.
|
|
Origin string
|
|
// File is used in error messages.
|
|
File string
|
|
// MaxIncludeDepth bounds $INCLUDE recursion; 0 disables $INCLUDE.
|
|
MaxIncludeDepth int
|
|
// BaseDir is where relative $INCLUDE paths resolve; empty means the
|
|
// directory of the primary file.
|
|
BaseDir string
|
|
// DefaultTTL is the TTL applied to records that carry none, when the
|
|
// file has neither $TTL nor an SOA.
|
|
DefaultTTL uint32
|
|
}
|
|
|
|
// Parse reads a master file from r.
|
|
func Parse(r io.Reader, opt Options) (*Zone, error) {
|
|
origin := ensureDot(opt.Origin)
|
|
if origin == "" {
|
|
origin = "."
|
|
}
|
|
if opt.BaseDir == "" && opt.File != "" {
|
|
opt.BaseDir = dirOf(opt.File)
|
|
}
|
|
z := &Zone{Origin: origin, File: opt.File, DefaultTTL: opt.DefaultTTL}
|
|
|
|
var lastOwner string
|
|
lineno := 0
|
|
lines := bufio.NewScanner(r)
|
|
lines.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
|
|
|
|
for lines.Scan() {
|
|
startLine := lineno + 1
|
|
lineno++
|
|
stripped := strings.TrimSpace(stripComment(lines.Text()))
|
|
if stripped == "" {
|
|
continue
|
|
}
|
|
// Fold parenthesised continuations into one logical line.
|
|
logical, err := fold(lines, stripped, &lineno, opt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s:%d: %w", where(opt.File), startLine, err)
|
|
}
|
|
if strings.HasPrefix(logical, "$") {
|
|
if err := z.directive(logical, opt); err != nil {
|
|
return nil, fmt.Errorf("%s:%d: %w", where(opt.File), startLine, err)
|
|
}
|
|
lastOwner = z.Origin
|
|
continue
|
|
}
|
|
assembled, hasTTL, err := z.assemble(logical, lastOwner)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s:%d: %w", where(opt.File), startLine, err)
|
|
}
|
|
rr, err := dns.NewRR(assembled)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s:%d: %q: %w", where(opt.File), startLine, assembled, err)
|
|
}
|
|
// RFC 1035 §5.2: a record with no TTL column takes the $TTL in force.
|
|
// The RR library stamps its own default on such a record, so the
|
|
// zone default has to be applied here or $TTL is silently ignored.
|
|
if !hasTTL && z.DefaultTTL > 0 {
|
|
rr.Header().Ttl = z.DefaultTTL
|
|
}
|
|
// assemble has already qualified the RDATA names that were written
|
|
// relative to the origin, so the record holds no relative name.
|
|
rec := Record{
|
|
Name: strings.ToLower(ensureDot(rr.Header().Name)),
|
|
TTL: rr.Header().Ttl,
|
|
RR: rr,
|
|
Line: startLine,
|
|
File: opt.File,
|
|
}
|
|
if rec.TTL == 0 {
|
|
rec.TTL = z.DefaultTTL
|
|
}
|
|
if rec.TTL > 0 && (z.MinTTL == 0 || rec.TTL < z.MinTTL) {
|
|
z.MinTTL = rec.TTL
|
|
}
|
|
if soa, ok := rr.(*dns.SOA); ok {
|
|
z.SOA = cloneSOA(soa)
|
|
// An explicit $TTL wins; the SOA only supplies the default for a
|
|
// file that never set one.
|
|
if !z.ttlDirective {
|
|
if rec.TTL > 0 {
|
|
z.DefaultTTL = rec.TTL
|
|
} else if z.DefaultTTL == 0 {
|
|
z.DefaultTTL = soa.Minttl
|
|
}
|
|
}
|
|
}
|
|
z.Records = append(z.Records, rec)
|
|
lastOwner = rec.Name
|
|
}
|
|
if err := lines.Err(); err != nil {
|
|
return nil, fmt.Errorf("%s: read: %w", opt.File, err)
|
|
}
|
|
return z, nil
|
|
}
|
|
|
|
// fold joins a line with any parenthesised continuation lines still pending on
|
|
// the scanner. open reports how many continuations are still outstanding after
|
|
// the returned line, so a caller can loop until it reaches zero.
|
|
func fold(lines *bufio.Scanner, first string, lineno *int, opt Options) (string, error) {
|
|
depth := strings.Count(first, "(") - strings.Count(first, ")")
|
|
var sb strings.Builder
|
|
sb.WriteString(deparen(first))
|
|
for depth > 0 {
|
|
if !lines.Scan() {
|
|
return "", fmt.Errorf("unterminated parenthesis")
|
|
}
|
|
*lineno++
|
|
next := strings.TrimSpace(stripComment(lines.Text()))
|
|
if next == "" {
|
|
continue
|
|
}
|
|
depth += strings.Count(next, "(") - strings.Count(next, ")")
|
|
sb.WriteString(" ")
|
|
sb.WriteString(deparen(next))
|
|
}
|
|
return strings.TrimSpace(sb.String()), nil
|
|
}
|
|
|
|
// deparen removes continuation markers, leaving a single-space token stream.
|
|
func deparen(s string) string {
|
|
s = strings.ReplaceAll(s, "(", " ")
|
|
s = strings.ReplaceAll(s, ")", " ")
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
// assemble fills in the omitted owner name and class so that the result is a
|
|
// complete RR line that dns.NewRR will accept. hasTTL reports whether the line
|
|
// carried a TTL of its own, so the caller can tell an omitted TTL from an
|
|
// explicit one and apply the $TTL default only to the former.
|
|
func (z *Zone) assemble(line, lastOwner string) (string, bool, error) {
|
|
var hasTTL bool
|
|
fields := splitFields(line)
|
|
if len(fields) == 0 {
|
|
return "", false, fmt.Errorf("empty record")
|
|
}
|
|
var head string
|
|
var rest []string
|
|
if isRRTYPE(fields[0]) {
|
|
// Owner omitted: it repeats the previous record's owner.
|
|
head = lastOwner
|
|
rest = fields
|
|
} else {
|
|
head = fields[0]
|
|
rest = fields[1:]
|
|
}
|
|
if head == "" {
|
|
return "", false, fmt.Errorf("record has no owner name and no previous owner to inherit")
|
|
}
|
|
head = relative(head, z.Origin)
|
|
|
|
out := []string{head}
|
|
// Consume optional TTL and class in any order, then the type.
|
|
for len(rest) > 0 && !isRRTYPE(rest[0]) {
|
|
switch {
|
|
case isTTLToken(rest[0]):
|
|
out = append(out, rest[0])
|
|
hasTTL = true
|
|
case isClassToken(rest[0]):
|
|
out = append(out, strings.ToUpper(rest[0]))
|
|
default:
|
|
return "", false, fmt.Errorf("unexpected token %q before the record type", rest[0])
|
|
}
|
|
rest = rest[1:]
|
|
}
|
|
if len(rest) == 0 {
|
|
return "", false, fmt.Errorf("record %q has no type", head)
|
|
}
|
|
// RFC 1035 §5.1: a domain name in the RDATA section that is written
|
|
// without a trailing dot is relative to the zone origin, not to the
|
|
// root. It has to be qualified here, while it is still a token: the RR
|
|
// library resolves RDATA names against the root, so once dns.NewRR has
|
|
// run, "host" and "host." are indistinguishable and the origin would be
|
|
// lost.
|
|
qualifyRdata(rest[1:], rest[0], z.Origin)
|
|
out = append(out, rest...)
|
|
return strings.Join(out, " "), hasTTL, nil
|
|
}
|
|
|
|
// directive handles $TTL, $ORIGIN and $INCLUDE.
|
|
func (z *Zone) directive(line string, opt Options) error {
|
|
fields := strings.Fields(line)
|
|
switch strings.ToUpper(fields[0]) {
|
|
case "$TTL":
|
|
if len(fields) < 2 {
|
|
return fmt.Errorf("$TTL needs a value")
|
|
}
|
|
ttl, err := ParseTTL(fields[1])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
z.DefaultTTL = ttl
|
|
z.ttlDirective = true
|
|
case "$ORIGIN":
|
|
if len(fields) < 2 {
|
|
return fmt.Errorf("$ORIGIN needs a name")
|
|
}
|
|
z.Origin = ensureDot(fields[1])
|
|
case "$INCLUDE":
|
|
if len(fields) < 2 {
|
|
return fmt.Errorf("$INCLUDE needs a file name")
|
|
}
|
|
if opt.MaxIncludeDepth <= 0 {
|
|
return fmt.Errorf("$INCLUDE %q is disabled in this context", fields[1])
|
|
}
|
|
return fmt.Errorf("$INCLUDE %q is not supported; use blocklist.local_zones or a list source instead", fields[1])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// rrTypes is the set of record types the assembler recognises as a type
|
|
// token; anything absent is treated as an owner name.
|
|
var rrTypes = map[string]bool{}
|
|
|
|
func init() {
|
|
for _, t := range []uint16{
|
|
dns.TypeA, dns.TypeAAAA, dns.TypeNS, dns.TypeCNAME, dns.TypeSOA, dns.TypePTR,
|
|
dns.TypeMX, dns.TypeTXT, dns.TypeSRV, dns.TypeCAA, dns.TypeDS, dns.TypeDNSKEY,
|
|
dns.TypeNSEC, dns.TypeNSEC3, dns.TypeNSEC3PARAM, dns.TypeRRSIG, dns.TypeNSEC,
|
|
dns.TypeNAPTR, dns.TypeTLSA, dns.TypeSSHFP, dns.TypeSVCB, dns.TypeHTTPS,
|
|
dns.TypeHINFO, dns.TypeLOC, dns.TypeSPF, dns.TypeAXFR,
|
|
dns.TypeIXFR, dns.TypeANY, dns.TypeURI, dns.TypeDHCID, dns.TypeSMIMEA,
|
|
} {
|
|
rrTypes[dns.TypeToString[t]] = true
|
|
}
|
|
}
|
|
|
|
// cloneSOA copies an SOA because dns.SOA.Copy is unexported.
|
|
func cloneSOA(in *dns.SOA) *dns.SOA {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
out := *in // value copy is already deep: Ns/Mbox are plain strings
|
|
return &out
|
|
}
|
|
|
|
// rdataNameIndex maps an RR type to the positions of the domain names in its
|
|
// presentation-format RDATA, counted from the first token after the type. The
|
|
// position of a name follows from the type's field layout, so a relative name
|
|
// can be qualified while the line is still a token stream. A type that carries
|
|
// no name in its RDATA is absent, and so is a type assemble does not
|
|
// recognise, since such a record never reaches this point.
|
|
var rdataNameIndex = map[string][]int{
|
|
"CNAME": {0},
|
|
"HTTPS": {1}, // SvcPriority, TargetName, SvcParams...
|
|
"MX": {1}, // Preference, Exchange
|
|
"NAPTR": {5}, // Order, Preference, Flags, Service, Regexp, Replacement
|
|
"NS": {0},
|
|
"PTR": {0},
|
|
"SOA": {0, 1}, // MName, RName
|
|
"SRV": {3}, // Priority, Weight, Port, Target
|
|
"SVCB": {1},
|
|
}
|
|
|
|
// qualifyRdata appends the origin to the domain names in a record's RDATA that
|
|
// were written without a trailing dot: "www CNAME host" in example.com. means
|
|
// host.example.com. and not host. A name that already ends in a dot is
|
|
// absolute and left alone, which keeps the RPZ targets "." and "rpz-drop."
|
|
// and the null MX/SRV targets intact. rdata is the token slice after the type
|
|
// and is qualified in place.
|
|
func qualifyRdata(rdata []string, rrtype, origin string) {
|
|
for _, i := range rdataNameIndex[strings.ToUpper(rrtype)] {
|
|
if i < len(rdata) {
|
|
rdata[i] = relative(rdata[i], origin)
|
|
}
|
|
}
|
|
}
|
|
|
|
func isRRTYPE(s string) bool { return rrTypes[strings.ToUpper(s)] }
|
|
|
|
func isTTLToken(s string) bool {
|
|
if s == "" || s[0] < '0' || s[0] > '9' {
|
|
return false
|
|
}
|
|
if _, err := ParseTTL(s); err != nil {
|
|
return false
|
|
}
|
|
return !isClassToken(s)
|
|
}
|
|
|
|
func isClassToken(s string) bool {
|
|
switch strings.ToUpper(s) {
|
|
case "IN", "CH", "HS":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func where(file string) string {
|
|
if file == "" {
|
|
return "zone"
|
|
}
|
|
return file
|
|
}
|
|
|
|
func dirOf(p string) string {
|
|
i := strings.LastIndexByte(p, '/')
|
|
if i < 0 {
|
|
return "."
|
|
}
|
|
return p[:i]
|
|
}
|
|
|
|
func ensureDot(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
if strings.HasSuffix(s, ".") {
|
|
return s
|
|
}
|
|
return s + "."
|
|
}
|
|
|
|
// relative expands a possibly-relative name against origin.
|
|
func relative(name, origin string) string {
|
|
if name == "@" {
|
|
return ensureDot(origin)
|
|
}
|
|
// A name that already ends in a dot is absolute, so it never gets the
|
|
// origin appended. This is the common case in RPZ-style policy files,
|
|
// where owners are written fully qualified.
|
|
if strings.HasSuffix(name, ".") {
|
|
return name
|
|
}
|
|
if origin == "" || origin == "." {
|
|
return ensureDot(name)
|
|
}
|
|
return ensureDot(name) + origin
|
|
}
|
|
|
|
func stripComment(s string) string {
|
|
inQuote := false
|
|
for i := 0; i < len(s); i++ {
|
|
switch s[i] {
|
|
case '\\':
|
|
i++
|
|
case '"':
|
|
inQuote = !inQuote
|
|
case ';':
|
|
if !inQuote {
|
|
return s[:i]
|
|
}
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// splitFields splits on whitespace while keeping quoted strings intact (TXT
|
|
// payloads are handed to dns.NewRR untouched).
|
|
func splitFields(s string) []string {
|
|
var out []string
|
|
var cur strings.Builder
|
|
inQuote := false
|
|
started := false
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
switch {
|
|
case c == '\\' && i+1 < len(s):
|
|
cur.WriteByte(c)
|
|
i++
|
|
cur.WriteByte(s[i])
|
|
started = true
|
|
case c == '"':
|
|
inQuote = !inQuote
|
|
cur.WriteByte(c)
|
|
started = true
|
|
case (c == ' ' || c == '\t') && !inQuote:
|
|
if started {
|
|
out = append(out, cur.String())
|
|
cur.Reset()
|
|
started = false
|
|
}
|
|
default:
|
|
cur.WriteByte(c)
|
|
started = true
|
|
}
|
|
}
|
|
if started {
|
|
out = append(out, cur.String())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ParseTTL parses the RFC 1035 TTL grammar (unit suffixes s/m/h/d/w) and a
|
|
// bare number of seconds.
|
|
func ParseTTL(s string) (uint32, error) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return 0, fmt.Errorf("empty TTL")
|
|
}
|
|
var total uint64
|
|
i := 0
|
|
for i < len(s) {
|
|
start := i
|
|
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
|
i++
|
|
}
|
|
if start == i {
|
|
return 0, fmt.Errorf("bad TTL %q", s)
|
|
}
|
|
n, err := strconv.ParseUint(s[start:i], 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("bad TTL %q", s)
|
|
}
|
|
unitStart := i
|
|
for i < len(s) && (s[i] < '0' || s[i] > '9') {
|
|
i++
|
|
}
|
|
var mult uint64
|
|
switch strings.ToLower(s[unitStart:i]) {
|
|
case "", "s", "sec", "secs", "second", "seconds":
|
|
mult = 1
|
|
case "m", "min", "mins", "minute", "minutes":
|
|
mult = 60
|
|
case "h", "hour", "hours":
|
|
mult = 3600
|
|
case "d", "day", "days":
|
|
mult = 86400
|
|
case "w", "week", "weeks":
|
|
mult = 604800
|
|
default:
|
|
return 0, fmt.Errorf("bad TTL unit in %q", s)
|
|
}
|
|
total += n * mult
|
|
if total > 0xFFFFFFFF {
|
|
return 0, fmt.Errorf("TTL %q overflows", s)
|
|
}
|
|
}
|
|
return uint32(total), nil
|
|
}
|
|
|
|
// FormatTTL renders a TTL in the compact unit form operators expect.
|
|
func FormatTTL(ttl uint32) string {
|
|
switch {
|
|
case ttl >= 604800 && ttl%604800 == 0:
|
|
return strconv.FormatUint(uint64(ttl/604800), 10) + "w"
|
|
case ttl >= 86400 && ttl%86400 == 0:
|
|
return strconv.FormatUint(uint64(ttl/86400), 10) + "d"
|
|
case ttl >= 3600 && ttl%3600 == 0:
|
|
return strconv.FormatUint(uint64(ttl/3600), 10) + "h"
|
|
case ttl >= 60 && ttl%60 == 0:
|
|
return strconv.FormatUint(uint64(ttl/60), 10) + "m"
|
|
default:
|
|
return strconv.FormatUint(uint64(ttl), 10)
|
|
}
|
|
}
|
|
|
|
// SerialNext applies RFC 1982 serial arithmetic: "+N"/"-N" or a plain number.
|
|
func SerialNext(current uint32, spec string) (uint32, error) {
|
|
spec = strings.TrimSpace(spec)
|
|
switch {
|
|
case spec == "":
|
|
return 0, fmt.Errorf("empty serial")
|
|
case spec[0] == '+':
|
|
n, err := strconv.ParseUint(spec[1:], 10, 32)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("bad serial %q", spec)
|
|
}
|
|
return addSerial(current, uint32(n)), nil
|
|
case spec[0] == '-':
|
|
n, err := strconv.ParseUint(spec[1:], 10, 32)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("bad serial %q", spec)
|
|
}
|
|
return addSerial(current, -uint32(n)), nil
|
|
default:
|
|
n, err := strconv.ParseUint(spec, 10, 32)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("bad serial %q", spec)
|
|
}
|
|
return uint32(n), nil
|
|
}
|
|
}
|
|
|
|
func addSerial(a, b uint32) uint32 {
|
|
sum := (uint64(a) + uint64(b)) & 0xFFFFFFFF
|
|
// Both operands in the "high" half means the sum stays inside the epoch,
|
|
// so no wrap correction applies (RFC 1982 §3.2).
|
|
if a >= 0x80000000 && b >= 0x80000000 {
|
|
return uint32(sum)
|
|
}
|
|
if sum >= 0x100000000 {
|
|
return uint32(sum - 0x100000000)
|
|
}
|
|
return uint32(sum)
|
|
}
|
|
|
|
// ParseFile reads and parses a master file from disk.
|
|
func ParseFile(path string, origin string) (*Zone, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
return Parse(f, Options{Origin: origin, File: path, BaseDir: dirOf(path)})
|
|
}
|
|
|
|
// ParseFiles parses every file in paths and merges the records into one zone.
|
|
// Origins default to the file's base name, which is what a directory of
|
|
// per-list policy zones usually wants.
|
|
func ParseFiles(paths []string, originFor func(string) string) (*Zone, error) {
|
|
z := &Zone{Origin: "."}
|
|
for _, p := range paths {
|
|
origin := originFor(p)
|
|
sub, err := ParseFile(p, origin)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
z.Records = append(z.Records, sub.Records...)
|
|
if sub.DefaultTTL > 0 {
|
|
z.DefaultTTL = sub.DefaultTTL
|
|
}
|
|
if sub.SOA != nil {
|
|
z.SOA = sub.SOA
|
|
}
|
|
}
|
|
return z, nil
|
|
}
|
|
|
|
// OriginFromPath derives a zone origin from a file path: "rpz/ads.zone" and
|
|
// "ads.zone" both become "ads.zone.".
|
|
func OriginFromPath(p string) string {
|
|
base := p
|
|
if i := strings.LastIndexByte(base, '/'); i >= 0 {
|
|
base = base[i+1:]
|
|
}
|
|
if i := strings.IndexByte(base, '.'); i > 0 {
|
|
base = base[:i]
|
|
}
|
|
return ensureDot(base)
|
|
}
|