The zone parser ignored an explicit $TTL and stamped every record with the SOA minimum instead, so a 60s zone served 3600s and DefaultTTL was wrong too. RFC 1035 §5.2: an explicit $TTL wins. qualifyRdata was also added to apply RFC 1035 §5.1 relative-name expansion to RDATA, which is correct but has one outstanding defect noted below. make check-go is the tight inner loop: gofmt check, go vet, compile. It uses go-build rather than build, because build depends on web and shells out to npm to rebuild a console bundle that rarely changes. check now depends on it, so the fast path and the CI path cannot drift apart. Outstanding, not fixed here: a relative CNAME target is still emitted as "host." instead of "host.example.com.". qualifyRdata is called with the right origin, and relative() is correct in isolation, so the target is almost certainly being dot-terminated in Zone.assemble before dns.NewRR ever sees it, which makes relative() treat it as already absolute. Narrowed to that, not confirmed — see the localzone test failures.
598 lines
16 KiB
Go
598 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
|
|
}
|
|
// 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. Left to the library it becomes "host.", which points a CNAME
|
|
// at the root instead of at the zone.
|
|
qualifyRdata(rr, z.Origin)
|
|
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)
|
|
}
|
|
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
|
|
}
|
|
|
|
// qualifyRdata appends the origin to the domain names in a record's RDATA
|
|
// that were written without a trailing dot. RFC 1035 §5.1 makes those names
|
|
// relative to the zone origin, so "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.
|
|
func qualifyRdata(rr dns.RR, origin string) {
|
|
switch v := rr.(type) {
|
|
case *dns.CNAME:
|
|
v.Target = relative(v.Target, origin)
|
|
case *dns.DNAME:
|
|
v.Target = relative(v.Target, origin)
|
|
case *dns.NS:
|
|
v.Ns = relative(v.Ns, origin)
|
|
case *dns.PTR:
|
|
v.Ptr = relative(v.Ptr, origin)
|
|
case *dns.MX:
|
|
v.Mx = relative(v.Mx, origin)
|
|
case *dns.SRV:
|
|
v.Target = relative(v.Target, origin)
|
|
case *dns.SOA:
|
|
v.Ns = relative(v.Ns, origin)
|
|
v.Mbox = relative(v.Mbox, origin)
|
|
case *dns.NAPTR:
|
|
v.Replacement = relative(v.Replacement, origin)
|
|
case *dns.RP:
|
|
v.Mbox = relative(v.Mbox, origin)
|
|
v.Txt = relative(v.Txt, origin)
|
|
case *dns.MINFO:
|
|
v.Rmail = relative(v.Rmail, origin)
|
|
v.Email = relative(v.Email, origin)
|
|
case *dns.MB:
|
|
v.Mb = relative(v.Mb, origin)
|
|
case *dns.MG:
|
|
v.Mg = relative(v.Mg, origin)
|
|
case *dns.MR:
|
|
v.Mr = relative(v.Mr, origin)
|
|
case *dns.KX:
|
|
v.Exchanger = relative(v.Exchanger, 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)
|
|
}
|