diff --git a/.githooks/pre-commit/01-docgen b/.githooks/pre-commit/01-docgen index 8158eef..a13ed62 100755 --- a/.githooks/pre-commit/01-docgen +++ b/.githooks/pre-commit/01-docgen @@ -15,23 +15,59 @@ for f in $(find . -type f -iname "README.adoc"); do nosuffix="${filename%.*}" pfx="${docsdir}/${nosuffix}" + # Render HTML, include in commit newf="${pfx}.html" asciidoctor -a ROOTDIR="${orig}/" -o "${newf}" "${f}" echo "Generated ${newf} from ${f}" git add "${newf}" + + # If asciidoctor-pdf is installed, render as PDF for local use + # (Does not get added to commit, and *.pdf is in .gitignore for a reason) if command -v asciidoctor-pdf &> /dev/null; then newf="${pfx}.pdf" asciidoctor-pdf -a ROOTDIR="${orig}/" -o "${newf}" "${f}" fi + + # If pandoc is installed, render to "GitHub-flavored Markdown" for better rendering on forks/mirrors + # and marginally better rendering on https://pkg.go.dev/ and add to commit. + # + # + # There is no such thing as "Markdown". + # The closest thing you have to any sort of standard is https://daringfireball.net/projects/markdown/ + # but everybody and their mother adds their own "extensions"/"flavor", and sometimes even + # change how formatting works compared to the Daring Fireball/John Gruber spec (the original creator of the "syntax"). + # Ergo "Markdown" inherently has no meaning. + # It's one of the worst formatting languages out there - just because it's popular doesn't mean it's good. + # + # If you're writing docs, you should stick to one of these which have defined, canonical, standardized + # syntax: + # * AsciiDoc/AsciiDoctor + # * Supports much more extensive formatting than any Markdown flavor I've seen + # * Source/raw/unrendered still *quite* readable by human eyes + # * Somewhat limited parsers/renderers + # * https://asciidoc.org/ + # * https://asciidoctor.org/ + # * DocBook + # * Supports even more extensive and flexible but exact formatting + # * Great for publishing, though - especially if you need control over formatting/layout + # * XML-based + # * Harder to read in plaintext, but fairly doable (XML lends to decent mental rendering) + # * Very wide support for parsing/rendering + # * https://docbook.org/ + # * LaTex + # * Allows for *very* extensive domain-specific ligature/representation (very common in mathematic/scientific literature) + # * But nigh unreadable by human eyes unless you've rather familiar with it + # * Parsing/rendering support about on-par with DocBook + # * https://www.latex-project.org/ + # if command -v pandoc &> /dev/null; then newf="${pfx}.md" set +e #asciidoctor -a ROOTDIR="${orig}/" -b docbook -o - "${f}" | pandoc -f docbook -t markdown_strict -o "${newf}" - #asciidoctor -a ROOTDIR="${orig}/" -b html -o - "${f}" | pandoc -f html -t markdown_strict -o "${newf}" asciidoctor -a ROOTDIR="${orig}/" -b html -o - "${f}" | pandoc -f html -t gfm -o "${newf}" if [ $? -eq 0 ]; then diff --git a/netx/dnsx/errors.go b/netx/dnsx/errors.go new file mode 100644 index 0000000..c76fc2a --- /dev/null +++ b/netx/dnsx/errors.go @@ -0,0 +1,12 @@ +package dnsx + +import ( + `errors` +) + +var ( + ErrBadChars error = errors.New("netx/dnsx: invalid characters/encoding were encountered") + ErrBadLabelLen error = errors.New("netx/dnsx: a label with invalid length was encountered") + ErrBadPtrLen error = errors.New("netx/dnsx: a PTR record with invalid length was encountered") + ErrBadPtrRoot error = errors.New("netx/dnsx: a PTR record with invalid root encountered") +) diff --git a/netx/dnsx/funcs.go b/netx/dnsx/funcs.go new file mode 100644 index 0000000..d1c7c23 --- /dev/null +++ b/netx/dnsx/funcs.go @@ -0,0 +1,571 @@ +package dnsx + +import ( + `bytes` + `encoding/base32` + `fmt` + `math` + `net` + `net/netip` + `strings` + + `go4.org/netipx` + `r00t2.io/goutils/stringsx` +) + +/* +AddrFromPtr returns a [net/netip.Addr] from a PTR record. + +It is the inverse of [AddrToPtr]. + +See also [IpFromPtr]. +*/ +func AddrFromPtr(s string) (ip netip.Addr, err error) { + + var idx int + var ipStr string + var tmpStr string + var spl []string = strings.Split(strings.TrimSuffix(s, "."), ".") + + switch len(spl) { + case 6: + if strings.Join(spl[4:], ".") != "in-addr.arpa" { + err = ErrBadPtrRoot + return + } + ipStr = fmt.Sprintf("%s.%s.%s.%s", spl[3], spl[2], spl[1], spl[0]) + case 34: + if strings.Join(spl[32:], ".") != "ip6.arpa" { + err = ErrBadPtrRoot + return + } + tmpStr = stringsx.Reverse(strings.ReplaceAll(strings.Join(spl[:32], ""), ".", "")) + for idx = 0; idx < len(tmpStr); idx++ { + if idx%4 == 0 && idx != 0 { + ipStr += ":" + } + ipStr += string(rune(tmpStr[idx])) + } + default: + err = ErrBadPtrLen + return + } + + if ip, err = netip.ParseAddr(ipStr); err != nil { + return + } + + return +} + +/* +AddrToPtr returns a PTR record from ip. + +It is the inverse of [AddrFromPtr]. + +It includes the root label at the end (the trailing period). +*/ +func AddrToPtr(ip netip.Addr) (s string) { + + var idx int + var b []byte + var ipStr string + + if ip.Is6() { + ipStr = stringsx.Reverse(strings.ReplaceAll(ip.StringExpanded(), ":", "")) + ipStr = strings.Join( + strings.Split(ipStr, ""), + ".", + ) + s = fmt.Sprintf("%s.ip6.arpa.", ipStr) + } else { + b = make([]byte, 4) + copy(b, ip.AsSlice()) + for idx = len(b) - 1; idx >= 0; idx-- { + ipStr += fmt.Sprintf("%d.", b[idx]) + } + s = fmt.Sprintf("%s.in-addr.arpa.", ipStr) + } + + return +} + +/* +DnsStrToWire returns a wire-format of a DNS name. + +No validation or conversion (other than to wire format) is performed, +and it is expected that any IDN(A)/Punycode translation has +*already been performed* such that recordNm is in the ASCII form. +(See [IsFqdn] for more information on IDN(A)/Punycode.) + +For encoding reasons, if any given label/segment has a length of 0 or greater than 255 ([math.MaxUint8]), +[ErrBadLabelLen] will be returned. + +See [DnsWireToStr] for the inverse. +*/ +func DnsStrToWire(recordNm string) (recordNmBytes []byte, err error) { + + var cLen int + var c []byte + var cStr string + var spl []string + var buf *bytes.Buffer = new(bytes.Buffer) + + spl = strings.Split(strings.TrimSuffix(recordNm, "."), ".") + for _, cStr = range spl { + c = []byte(cStr) + cLen = len(c) + if !(cLen > 0 && cLen <= math.MaxUint8) { + err = ErrBadLabelLen + return + } + buf.Write(append([]byte{uint8(cLen)}, c...)) + } + + recordNmBytes = append(buf.Bytes(), 0x00) + + return +} + +/* +DnsWireToStr is the inverse of [DnsStrToWire]. A trailing . is not included. + +For decoding reasons, it will exit with [ErrBadLabelLen] if recordNmBytes is nil/empty or +if no terminating nullbyte is found after 256 label characters have been encountered. +*/ +func DnsWireToStr(recordNmBytes []byte) (recordNm string, err error) { + + var c []byte + var cLen uint8 + var arrLen int + var numChars int + var labels []string + var buf *bytes.Buffer + + if recordNmBytes == nil || len(recordNmBytes) == 0 { + err = ErrBadLabelLen + return + } + buf = bytes.NewBuffer(recordNmBytes) + labels = make([]string, 0) + + arrLen = len(recordNmBytes) + for { + if cLen, err = buf.ReadByte(); err != nil { + return + } + if cLen == 0x00 { + break + } + numChars += int(cLen) + if numChars > 255 { + err = ErrBadLabelLen + return + } + if numChars > arrLen { + err = ErrBadLabelLen + return + } + c = buf.Next(int(cLen)) + labels = append(labels, string(c)) + } + + recordNm = strings.Join(labels, ".") + + return +} + +/* +IpFromPtr is like [AddrFromPtr] but with a [net.IP] instead. + +It is the inverse of [IpToPtr]. +*/ +func IpFromPtr(s string) (ip net.IP, err error) { + + var a netip.Addr + + if a, err = AddrFromPtr(s); err != nil { + return + } + ip = net.IP(a.AsSlice()) + + return +} + +/* +IpToPtr is like [AddrToPtr] but with a [net.IP] instead. + +It is the inverse of [IpFromPtr]. +*/ +func IpToPtr(ip net.IP) (s string) { + + var a netip.Addr + + a, _ = netipx.FromStdIP(ip) + s = AddrToPtr(a) + + return +} + +/* +IsFqdn returns a boolean indicating if s is an FQDN that strictly adheres to RFC format requirements. +It performs no lookups/resolution attempts or network operations otherwise. + +It will return true for the "apex record" (e.g. the "naked domain"), as this is a valid assignable FQDN. + +It will return false for wildcard records (see [IsFqdnWildcard]). + +s may or may not end in a period (the root zone; "absolute" FQDNs) (0x00 in wire format). + +# TLDs + +Because valid TLDs are fairly dynamic and can change frequently, +validation is *not* performed against the TLD itself. +This only ensures that s has a TLD label conforming to the character rules in the referenced RFCs. +See [golang.org/x/net/publicsuffix] if precise TLD validation is required (though true TLD validation generally +requires fetching the current TLD lists from IANA at runtime like [github.com/bombsimon/tld-validator]). + +# Special RFC-Defined Accommodations + +RFC 2181 [§ 11] specifies that site-local DNS software may accommodate non-RFC-conforming rules. +This function may and likely will return false for these site-local deviations. +The Lookup* functions/mthods in [net] should be used to validate in these casts +if that accommodation is necessary. + +Note that underscores are not valid for "true" FQDNs as they are only valid for e.g. SRV record names, +TXT records, etc. - not A/AAAA/CNAME, etc. - see RFC 8553 for details. + +See the following functions for allowing additional syntax/rule validation +that have record-type-specific accommodations made: + + * [IsFqdnDefinedTxt] + * [IsFqdnNsec3] + * [IsFqdnSrv] + * [IsFqdnWildcard] + +# RFC Coverage + +This function should conform properly to: + + * RFC 952 + * RFC 1034 and RFC 1035 + * RFC 1123 + * RFC 2181 (selectively, see above) + +preferring the most up-to-date rules where relevant (e.g. labels may start with digits, as per RFC 1123). +It enforces/checks label and overall length limits as defined by RFC. + +# IDN(A) and Punycode + +Note that it expects the ASCII-only/presentation form of a record name and +will not perform any IDN/IDNA nor Punycode translation. +If a caller anticipates FQDNs in their localized format, +the caller must perform translation first +(via e.g. [gitlab.com/golang-commonmark/puny], [golang.org/x/net/idna], etc.). + +To reiterate, IDN/IDNA: + + * RFC 3490 + * RFC 5890 + * RFC 5891 + * RFC 5892 + * RFC 5893 + * RFC 5894 + +and Punycode (RFC 3492) *MUST* use their ASCII forms, NOT the localized/Unicode formats. + +[§ 11]: https://datatracker.ietf.org/doc/html/rfc2181#section-11 +*/ +func IsFqdn(s string) (fqdn bool) { + + var lbl string + var lbls []string = strings.Split(strings.TrimSuffix(s, "."), ".") + + if !commonFqdn(s) { + return + } + for _, lbl = range lbls { + if !IsLabel(lbl) { + return + } + } + + fqdn = true + + return +} + +/* +IsFqdnDefinedTxt is like [IsFqdn] but explicitly *only* allows fully-qualified +RFC-defined TXT "subtypes": + + * ACME DNS-01 (RFC 8555) + * BIMI (RFC draft [bimi]) + * DKIM (RFC 6376, RFC 8301, RFC 8463) + * DKIM ATPS (RFC 6541) + * DMARC (RFC 7489, RFC 9091, RFC 9616) + * MTA-STS (RFC 8461) + * TLSRPT (RFC 8460) + +Note that the following TXT "subtypes" do not have special formatting in labels/name, +and thus are not covered by this function: + + * SPF (RFC 4408, RFC 7208) + +[bimi]: https://datatracker.ietf.org/doc/html/draft-brand-indicators-for-message-identification +*/ +func IsFqdnDefinedTxt(fqdn string) (isOk bool) { + + var lbls []string = strings.Split(fqdn, ".") + + if lbls == nil || len(lbls) < 2 { + return + } + + switch lbls[0] { + case "_dmarc", "_mta-sts", "_acme-challenge": + if len(lbls) < 2 { + return + } + isOk = IsFqdn(strings.Join(lbls[1:], ".")) + case "_smtp": + if len(lbls) <= 2 { + return + } + if lbls[1] != "_tls" { + return + } + isOk = IsFqdn(strings.Join(lbls[2:], ".")) + default: + if !IsLabel(lbls[0]) { + return + } + switch lbls[1] { + case "_domainkey", "_atps", "_bimi": + isOk = IsFqdn(strings.Join(lbls[2:], ".")) + } + } + + // TODO + + return +} + +/* +IsFqdnNsec3 confirms (partially) that s is a valid NSEC3 record name. + +Note that due to the record name being a base32 encoding of a *hash*, the validity +can't be 100% confirmed with certainty - only basic checks can be done. + +NSEC3 can be found via: + + * RFC 5155 + * RFC 6840 + * RFC 6944 + * RFC 7129 + * RFC 8198 + * RFC 9077 + * RFC 9157 + * RFC 9276 + * RFC 9905 + +At the time of writing, only one hashing algorithm (SHA-1) has been specified. +However, because this function does not check against the IANA registration at runtime, +it's possible that this changes but the library may not immediately reflect this. +*/ +func IsFqdnNsec3(s string) (maybeNsec3 bool) { + + var h []byte + var err error + var isAscii bool + var lbls []string = strings.Split(strings.TrimSuffix(s, "."), ".") + + if !commonFqdn(s) { + return + } + if len(lbls) <= 2 { + return + } + if len(lbls[0]) != 32 { // SHA1 is 160 bits/20 bytes digest, which is always 32 chars in base32(hex) + return + } + if h, err = base32.StdEncoding.DecodeString(strings.ToUpper(lbls[0])); err != nil { + return + } + if isAscii, err = stringsx.IsAsciiSpecial( + strings.ToLower(lbls[0]), + false, false, false, false, + []byte{ + // Normally, Base32 goes A-Z, 2-7 + // but NSEC3 uses Base32Hex (RFC 4648 § 7), + // which is 0-9A-V + '0', '1', '2', '3', '4', '5', + '6', '7', '8', '9', 'a', 'b', + 'c', 'd', 'e', 'f', 'g', 'h', + 'j', 'k', 'm', 'n', 'p', 'q', + 'r', 's', 't', 'u', 'v', + }, + nil, + ); err != nil { + return + } + if isAscii { + return + } + if len(h) != 20 { + return + } + maybeNsec3 = IsFqdn(strings.Join(lbls[1:], ".")) + + return +} + +/* +IsFqdnSrv is like [IsFqdn], but explicitly *only* allows fully-qualified SRV records +(i.e. underscores must start the first two labels, and there must be at least two additional +labels after these labels). + +Note that the protocol is not checked for validity, as that would require runtime +validation against a resource liable to change and would need to be fetched dynamically - see +the [IANA Protocol Numbers registry]. + +[IANA Protocol Numbers registry]: https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml +*/ +func IsFqdnSrv(s string) (srv bool) { + + var lbls []string = strings.Split(strings.TrimSuffix(s, "."), ".") + + if !commonFqdn(s) { + return + } + if len(lbls) <= 4 { + return + } + + if !strings.HasPrefix(lbls[0], "_") { + return + } + if !strings.HasPrefix(lbls[1], "_") { + return + } + srv = IsFqdn(strings.Join(lbls[2:], ".")) + + return +} + +// IsFqdnWildcard is like [IsFqdn] but explicitly *only* allows fully-qualified wildcard records. +func IsFqdnWildcard(s string) (wildcard bool) { + + var lbls []string = strings.Split(strings.TrimSuffix(s, "."), ".") + + if len(lbls) < 2 { + return + } + + if lbls[0] != "*" { + return + } + wildcard = IsFqdn(strings.Join(lbls[1:], ".")) + + return +} + +// IsLabel returns true if s is a valid DNS label for standard records. +func IsLabel(s string) (isLbl bool) { + + var err error + + if strings.HasPrefix(s, "-") { + return + } + if strings.HasSuffix(s, "-") { + return + } + if isLbl, err = stringsx.IsAsciiSpecial( + s, + false, false, false, false, + []byte{ + 'a', 'b', 'c', 'd', 'e', 'f', + 'g', 'h', 'i', 'j', 'k', 'l', + 'm', 'n', 'o', 'p', 'q', 'r', + 's', 't', 'u', 'v', 'w', 'x', + 'y', 'z', '0', '1', '2', '3', + '4', '5', '6', '7', '8', '9', + '-', + }, + nil, + ); err != nil { + return + } + + return +} + +/* +IsPtr returns true if s is a PTR (also called an "rDNS" or "reverse DNS" record) name. + +If true, the IP is returned as well (otherwise it will be nil). +*/ +func IsPtr(s string) (isPtr bool, addr net.IP) { + + var err error + var lbls []string = strings.Split(strings.TrimSuffix(s, "."), ".") + + if len(lbls) < 6 { + return + } + + if _, err = AddrFromPtr(s); err != nil { + return + } + + isPtr = true + + return +} + +// commonFqdn is used to validate some rules common to all record names. +func commonFqdn(s string) (isOk bool) { + + var err error + var lbl string + var isAscii bool + var labels []string + var domstr string = strings.ToLower(strings.TrimSuffix(s, ".")) + + if isAscii, err = stringsx.IsAsciiSpecial( + domstr, false, false, false, false, + []byte{ + 'a', 'b', 'c', 'd', 'e', 'f', + 'g', 'h', 'i', 'j', 'k', 'l', + 'm', 'n', 'o', 'p', 'q', 'r', + 's', 't', 'u', 'v', 'w', 'x', + 'y', 'z', '0', '1', '2', '3', + '4', '5', '6', '7', '8', '9', + '-', '_', '.', + }, + nil, + ); err != nil { + return + } + if !isAscii { + return + } + + if (len(domstr) + 1) > 255 { // +1 for root label + return + } + labels = strings.Split(domstr, ".") + + for _, lbl = range labels { + if len(lbl) < 1 || len(lbl) > 63 { + return + } + } + + // TODO? + + isOk = true + + return +} diff --git a/netx/dnsx/funcs_test.go b/netx/dnsx/funcs_test.go new file mode 100644 index 0000000..a08a252 --- /dev/null +++ b/netx/dnsx/funcs_test.go @@ -0,0 +1,29 @@ +package dnsx + +import ( + `net/netip` + "testing" +) + +func TestPtr(t *testing.T) { + + var err error + var ptr string + var ip netip.Addr + var ipStr string = "::ffff:192.168.0.1" + var ptrStr string = "1.0.0.0.8.a.0.c.f.f.f.f.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa." + + if ip, err = AddrFromPtr(ptrStr); err != nil { + t.Fatal(err) + } + t.Logf("PTR -> Addr: %s -> %s", ptrStr, ip.String()) + if ip.String() != ipStr { + t.Fatalf("expect IP %v, got %v", ipStr, ip.String()) + } + + ptr = AddrToPtr(ip) + if ptr != ptrStr { + t.Fatalf("expect PTR %v, got %v", ptrStr, ptr) + } + t.Logf("Addr -> PTR: %s -> %s", ip.String(), ptr) +} diff --git a/netx/funcs.go b/netx/funcs.go index 3ae6135..5aea8f8 100644 --- a/netx/funcs.go +++ b/netx/funcs.go @@ -102,11 +102,37 @@ func Cidr4ToStr(cidr uint8) (maskStr string, err error) { return } +/* +FamilyToVer returns a more "human-friendly" IP version from a system/lower-level IP family +([AFUnspec], [AFInet], [AFInet6]). + +ipVer will be int(4) for [AFInet], int(6) for [AFInet6], int(0) for [AFUnspec], or +int(-1) for an unknown family. +*/ +func FamilyToVer(family uint16) (ipVer int) { + + switch family { + case AFInet: + ipVer = 4 + case AFInet6: + ipVer = 6 + case AFUnspec: + ipVer = 0 + default: + ipVer = -1 + } + + return +} + /* GetAddrFamily returns the network family of a [net/netip.Addr]. See also [GetIpFamily]. +Note that this returns [AFInet] or [AFInet6], NOT uint16(4) or uint16(6). +(See [FamilyToVer] to get the associated higher-level value.) + If addr is not a "valid" IP address or the version can't be determined, family will be AFUnspec (usually 0x00/0). */ func GetAddrFamily(addr netip.Addr) (family uint16) { @@ -131,6 +157,9 @@ func GetAddrFamily(addr netip.Addr) (family uint16) { /* GetIpFamily returns the network family of a [net.IP]. +Note that this returns [AFInet] or [AFInet6], NOT uint16(4) or uint16(6). +(See [FamilyToVer] to get the associated higher-level value.) + See also [GetAddrFamily]. If ip is not a "valid" IP address or the version can't be determined, @@ -158,6 +187,8 @@ If ip is an IPv4 address, it will simmply be the string representation (e.g. "20 If ip is an IPv6 address, it will be enclosed in brackets (e.g. "[2001:db8::1]"). If the version can't be determined, rfcStr will be an empty string. + +See also [IpRfcStr] for providing an IP address as a string. */ func IpRfc(ip net.IP) (rfcStr string) { @@ -170,6 +201,56 @@ func IpRfc(ip net.IP) (rfcStr string) { return } +/* +IpRfcStr implements [IpRfc]/[AddrRfc] for string representations of an IP address s. + +If s is an IPv6 address already in the bracketed RFC format, +then rfcStr will be equal to s. + +If s is not a string representation of an IP address, rfcStr will be empty. + +See [IpStripRfcStr] for the inverse (removing any brackets from s if present). +*/ +func IpRfcStr(s string) (rfcStr string) { + + var ip net.IP + + if !IsIpAddr(s) { + return + } + if IsBracketedIp6(s) { + rfcStr = s + return + } + ip = net.ParseIP(s) + if ip == nil { + return + } + rfcStr = IpRfc(ip) + + return +} + +/* +IpStripRfcStr returns IP address string s without any brackets. + +If s is not a valid IP address, stripStr will be empty. +*/ +func IpStripRfcStr(s string) (stripStr string) { + + if !IsIpAddr(s) { + return + } + if !IsBracketedIp6(s) { + stripStr = s + return + } + stripStr = strings.TrimPrefix(s, "[") + stripStr = strings.TrimSuffix(stripStr, "]") + + return +} + /* IPMask4ToCidr returns a CIDR prefix size/bit size/bit length from a [net.IPMask]. @@ -257,6 +338,123 @@ func IPMask4ToStr(ipMask net.IPMask) (maskStr string, err error) { return } +/* +IpVerStr provides the IP family of IP address/network string s. + +s may be one of the following formats/syntaxes: + + * 203.0.113.0 + * 203.0.113.1 + * 203.0.113.0/24 + * 203.0.113.1/24 + * 2001:db8:: + * 2001:db8::1 + * 2001:db8::/32 + * 2001:db8::1/32 + * [2001:db8::] + * [2001:db8::1] + + +Unlike [GetAddrFamily]/[GetIpFamily], this returns a more "friendly" +version - if s is not valid syntax, ipVer will be int(0), +otherwise ipVer will be int(4) for family IPv4 and int(6) for family IPv6. +(See [VerToFamily] to get the associated system/lower-level value.) +*/ +func IpVerStr(s string) (ipVer int) { + + var err error + var ipstr string + var p netip.Prefix + + ipstr = strings.TrimPrefix(s, "[") + ipstr = strings.TrimSuffix(ipstr, "]") + + if p, err = netip.ParsePrefix(ipstr); err != nil { + return + } + if p.Addr().Is6() { + ipVer = 6 + } else { + ipVer = 4 + } + + return +} + +/* +IsBracketedIp6 returns a boolean indicating if s is a valid bracket-enclosed IPv6 in string format +(e.g. "[2001:db8::1]"). + +It will return false for *non-bracketed* IPv6 addresses (e.g. "2001:db8::1"), IPv4 addresses, +or if s is not a valid IPv6 address string. + +[IpRfcStr] or [IpStripRfcStr] can be used to coerce a string to a specific format. +*/ +func IsBracketedIp6(s string) (isBrktdIp bool) { + + var ip net.IP + var ipstr string + + if IpVerStr(s) != 6 { + return + } + + ipstr = strings.TrimPrefix(s, "[") + ipstr = strings.TrimSuffix(ipstr, "]") + + if ip = net.ParseIP(ipstr); ip == nil { + return + } + + isBrktdIp = ipstr == s + + return +} + +/* +IsIpAddr returns a boolean indicating if s is an IP address (either IPv4 or IPv6) in string format. + +For IPv6, it will return true for both of these formats: + + * 2001:db8::1 + * [2001:db8::1] + +[IsBracketedIp6] can be used to narrow down which form. +*/ +func IsIpAddr(s string) (isIp bool) { + + var err error + var a netip.Addr + + if a, err = netip.ParseAddr(s); err != nil { + return + } + + isIp = a.IsValid() + + return +} + +/* +IsPrefixNet returns true if s is a (valid) IP address or network (either IPv4 or IPv6) in: + + / + +format. +*/ +func IsPrefixNet(s string) (isNet bool) { + + var err error + var p netip.Prefix + + if p, err = netip.ParsePrefix(s); err != nil { + return + } + isNet = p.Masked().IsValid() + + return +} + /* Mask4ToCidr converts an IPv4 netmask *in bitmask form* to a CIDR prefix size/bit size/bit length. @@ -408,3 +606,25 @@ func Mask4StrToMask(maskStr string) (mask uint32, err error) { return } + +/* +VerToFamily takes a "human-readable" IP version ipVer (4 or 6) and returns +a system-level constant (e.g. [AFUnspec], [AFInet], [AFInet6]). + +If not a known IP version (i.e. neither 4 nor 6), family will be [AFUnspec]. + +It is the inverse of [FamilyToVer]. +*/ +func VerToFamily(ipVer int) (family uint16) { + + switch ipVer { + case 4: + family = AFInet + case 6: + family = AFInet6 + default: + family = AFUnspec + } + + return +} diff --git a/netx/funcs_test.go b/netx/funcs_test.go index 5d64762..b5740c2 100644 --- a/netx/funcs_test.go +++ b/netx/funcs_test.go @@ -7,6 +7,29 @@ import ( "testing" ) +func TestFuncsDns(t *testing.T) { + + var err error + var domBin []byte + var domStr string + var domEx string = "foo.r00t2.io" + + if domBin, err = DnsStrToWire(domEx); err != nil { + t.Fatal(err) + } + t.Logf("Domain %s to wire: %#x\n", domEx, domBin) + if domStr, err = DnsWireToStr(domBin); err != nil { + t.Fatal(err) + } + t.Logf("Domain wire %#x to string: %s\n", domBin, domStr) + if domStr != domEx { + t.Fatalf("DNS str wrong (%s != %s)\n)", domStr, domEx) + } + t.Logf("Domain string %s matches %s", domStr, domEx) + + return +} + func TestFuncsIP(t *testing.T) { var err error diff --git a/stringsx/func_asciiinvaliderror.go b/stringsx/func_asciiinvaliderror.go new file mode 100644 index 0000000..2d7b0c1 --- /dev/null +++ b/stringsx/func_asciiinvaliderror.go @@ -0,0 +1,18 @@ +package stringsx + +import ( + `fmt` +) + +// Error conforms an [AsciiInvalidError] to an error interface. +func (a *AsciiInvalidError) Error() (errStr string) { + + errStr = fmt.Sprintf( + "non-ASCII character '%c' at line:linepos %d:%d (byte %d), "+ + "string position %d (byte %d): bytes %#x, UTF-8 codepoint U+%04X", + a.BadChar, a.Line, a.LineChar, a.LineByte, + a.Char, a.Byte, a.BadBytes, a.BadChar, + ) + + return +} diff --git a/stringsx/funcs.go b/stringsx/funcs.go index 6b0bc75..9397573 100644 --- a/stringsx/funcs.go +++ b/stringsx/funcs.go @@ -1,11 +1,165 @@ package stringsx import ( + `bytes` + `errors` `fmt` + `io` + `slices` `strings` `unicode` ) +/* +IsAscii returns true if all characters in string s are ASCII. + +This simply wraps [IsAsciiSpecial]: + + isAscii, err = IsAsciiSpecial(s, allowCtl, true, allowExt, true, nil, nil) +*/ +func IsAscii(s string, allowCtl, allowExt bool) (isAscii bool, err error) { + + if isAscii, err = IsAsciiSpecial( + s, allowCtl, true, allowExt, true, nil, nil, + ); err != nil { + return + } + + return +} + +/* +IsAsciiBuf returns true if all of buffer buf is valid ASCII. + +Note that the buffer will be consumed/read by this function. + +This simply wraps [IsAsciiBufSpecial]: + + isAscii, err = IsAsciiBufSpecial(r, allowCtl, true, allowExt, true, nil, nil) +*/ +func IsAsciiBuf(r io.RuneReader, allowCtl, allowExt bool) (isAscii bool, err error) { + + if isAscii, err = IsAsciiBufSpecial( + r, allowCtl, true, allowExt, true, nil, nil, + ); err != nil { + return + } + + return +} + +/* +IsAsciiSpecial allows for specifying specific ASCII ranges. + +allowCtl, if true, will allow control characters (0x00 to 0x1f inclusive). + +allowPrint, if true, will allow printable characters (what most people think of +when they say "ASCII") (0x20 to 0x7f inclusive). + +allowExt, if true, will allow for "extended ASCII" - some later dialects expand +to a full 8-bit ASCII range (0x80 to 0xff inclusive). + +wsCtl, if true, "shifts" the "whitespace control characters" (\t, \n, \r) to the "printable" space +(such that allowPrint controls their validation). Thus: + + IsAsciiSpecial(s, false, true, false, true, nil, nil) + +has the same effect as specifying: + + IsAsciiSpecial(s, false, true, false, (-), []byte("\t\n\r"), nil) + +incl, if non-nil and non-empty, allows *additional* characters to be specified as included +that would normally *not* be allowed. + +excl, if non-nil and non-empty, invalidates on additional characters that would normally be allowed. + +excl, if specified, takes precedence over incl if specified. + +An [AsciiInvalidError] will be returned on the first encountered invalid character. +*/ +func IsAsciiSpecial(s string, allowCtl, allowPrint, allowExt, allowWs bool, incl, excl []byte) (isAscii bool, err error) { + + var buf *bytes.Buffer = bytes.NewBufferString(s) + + if isAscii, err = IsAsciiBufSpecial(buf, allowCtl, allowPrint, allowExt, allowWs, incl, excl); err != nil { + return + } + + return +} + +/* +IsAsciiBufSpecial is the same as [IsAsciiSpecial] but operates on an [io.RuneReader]. + +Note that the buffer will be consumed/read by this function. + +It will not return an [io.EOF] if encountered, but any other errors encountered will be returned. +It is expected that r will return an [io.EOF] when exhausted. + +An [AsciiInvalidError] will be returned on the first encountered invalid character. +*/ +func IsAsciiBufSpecial(r io.RuneReader, allowCtl, allowPrint, allowExt, allowWs bool, incl, excl []byte) (isAscii bool, err error) { + + var b rune + var bLen int + var nextNewline bool + var tmpErr *AsciiInvalidError = new(AsciiInvalidError) + // I know, I know. This is essentually a lookup table. Keeps it speedy. + var allowed [256]bool = getAsciiCharMap(allowCtl, allowPrint, allowExt, allowWs, incl, excl) + + for { + if b, bLen, err = r.ReadRune(); err != nil { + if errors.Is(err, io.EOF) { + err = nil + isAscii = true + } + return + } + // Set these *before* OK + if nextNewline { + tmpErr.Line++ + tmpErr.LineByte = 0 + tmpErr.LineChar = 0 + nextNewline = false + } else { + tmpErr.LineChar++ + } + tmpErr.Char++ + + if b == '\n' { + nextNewline = true + } + if b == rune(0xfffd) { + // not even valid unicode + tmpErr.BadChar = b + tmpErr.BadBytes = []byte(string(b)) + err = tmpErr + return + } + if bLen > 2 || b > 0xff { + // ASCII only occupies a single byte, ISO-8859-1 occupies 2 + tmpErr.BadChar = b + tmpErr.BadBytes = []byte(string(b)) + err = tmpErr + return + } + if !allowed[byte(b)] { + tmpErr.BadChar = b + tmpErr.BadBytes = []byte{byte(b)} + err = tmpErr + return + } + + // Set these *after* OK + tmpErr.LineByte += uint64(bLen) + tmpErr.Byte += uint64(bLen) + } + + isAscii = true + + return +} + /* LenSplit formats string `s` to break at, at most, every `width` characters. @@ -252,6 +406,18 @@ func Redact(s, maskStr string, leading, trailing uint, newlines bool) (redacted return } +// Reverse reverses string s. (It's absolutely insane that this isn't in stdlib.) +func Reverse(s string) (revS string) { + + var rsl []rune = []rune(s) + + slices.Reverse(rsl) + + revS = string(rsl) + + return +} + /* TrimLines is like [strings.TrimSpace] but operates on *each line* of s. It is *NIX-newline (`\n`) vs. Windows-newline (`\r\n`) agnostic. @@ -313,6 +479,58 @@ func TrimSpaceRight(s string) (trimmed string) { return } +// getAsciiCharMap returns a lookup "table" for ASCII characters. +func getAsciiCharMap(allowCtl, allowPrint, allowExt, allowWs bool, incl, excl []byte) (charmap [256]bool) { + + var idx uint8 + + if allowCtl { + for idx < 0x1f { + charmap[idx] = true + idx++ + } + } else { + idx = 0x1f + } + if allowPrint { + for idx < 0x7f { + charmap[idx] = true + idx++ + } + } else { + idx = 0x7f + } + if allowExt { + for { + charmap[idx] = true + if idx == 0xff { + break + } + idx++ + } + } else { + idx = 0xff + } + if allowWs { + charmap['\t'] = true + charmap['\n'] = true + charmap['\r'] = true + } + + if incl != nil && len(incl) > 0 { + for _, idx = range incl { + charmap[idx] = true + } + } + if excl != nil && len(excl) > 0 { + for _, idx = range excl { + charmap[idx] = false + } + } + + return +} + // getNewLine is too unpredictable/nuanced to be used as part of a public API promise so it isn't exported. func getNewLine(s string) (nl string) { diff --git a/stringsx/funcs_test.go b/stringsx/funcs_test.go index 9fd89b1..3404ee8 100644 --- a/stringsx/funcs_test.go +++ b/stringsx/funcs_test.go @@ -37,6 +37,17 @@ type ( } ) +func TestFuncsAscii(t *testing.T) { + + var err error + // var s string = "This is a §\nmulti-line\nstring 😀 with\nunicode text.\n" + var s string = "This is a §\nmulti-line\nstring with\nno unicode text.\n" + + if _, err = IsAscii(s, false, true); err != nil { + t.Fatal(err) + } +} + func TestRedact(t *testing.T) { var out string @@ -171,6 +182,18 @@ func TestRedact(t *testing.T) { } } +func TestReverse(t *testing.T) { + + var rev string + var s string = "012345679abcdef" + + rev = Reverse(s) + if rev != "fedcba976543210" { + t.Errorf("reverse of s '%s'; expected 'fedcba976543210', got '%s'", s, rev) + } + t.Logf("s: %s\nReverse: %s", s, rev) +} + func TestTrimLines(t *testing.T) { var out string diff --git a/stringsx/types.go b/stringsx/types.go new file mode 100644 index 0000000..483b650 --- /dev/null +++ b/stringsx/types.go @@ -0,0 +1,25 @@ +package stringsx + +type ( + /* + AsciiInvalidError is an error used to return an error for the IsAscii* validations. + + It is returned on the first found instance of an invalid ASCII character. + */ + AsciiInvalidError struct { + // Line is a 0-indexed line number where the invalid character was found. + Line uint64 + // LineByte is the 0-indexed byte position for the current Line. + LineByte uint64 + // LineChar is a 0-indexed character (rune) position where the invalid character was found on line number Line. + LineChar uint64 + // Byte is the 0-indexed byte position across the entire input. + Byte uint64 + // Char is the 0-indexed character (rune) position across the entire input. + Char uint64 + // BadChar is the invalid rune + BadChar rune + // BadBytes is BadChar as bytes. + BadBytes []byte + } +) diff --git a/tplx/sprigx/README.adoc b/tplx/sprigx/README.adoc index c361be2..c7372ce 100644 --- a/tplx/sprigx/README.adoc +++ b/tplx/sprigx/README.adoc @@ -17,6 +17,9 @@ Last rendered {localdatetime} // BEGIN variable attributes :sprig_ver: 3 :psutil_ver: 4 +:git_owner: r00t2 +:git_repo: go_goutils +:git_repo_full: {git_owner}/{git_repo} :mod_me: r00t2.io/goutils :pkg_me: tplx/sprigx :src_root: https://git.r00t2.io @@ -25,8 +28,12 @@ Last rendered {localdatetime} :mod_sprig: github.com/Masterminds/sprig/v{sprig_ver} :mod_psutil: github.com/shirou/gopsutil/v{psutil_ver} :import_sprig: {mod_sprig} -:src_base: {src_root}/r00t2/go_goutils/src/branch/master -:src_dir: {src_base}/{pkg_me} +:src_base: {src_root}/{git_repo_full} +:src_git: {src_base}.git +:src_tree: {src_base}/src/branch/master +:src_raw: {src_base}/raw/branch/master +:src_dir: {src_tree}/{pkg_me} +:src_dir_raw: {src_raw}/{pkg_me} :import_me: {mod_me}/{pkg_me} :godoc_me: {godoc_root}/{import_me} :godoc_sprig: {godoc_root}/{import_sprig} @@ -42,11 +49,57 @@ They provide functions that offer more enriched use cases and domain-specific da ==== If you are reading this README on the Go Module Directory documentation ({godoc_me}) or the directory landing page ({src_dir}), it may not render correctly. +Anchor-links (links within this document to other sections of this document) will likely also not work. -Be sure to view it at properly via {src_dir}/README.adoc[the AsciiDoc rendering^] -or by downloading and viewing the {src_dir}/README.html[HTML version^] and/or {src_dir}/README.pdf[PDF version^]. +Be sure to view it at properly via {src_dir}/README.adoc[the in-repo AsciiDoc rendering^] +or by downloading and viewing the {src_dir_raw}/README.html[HTML version^] in a browser locally +and/or <>. ==== +[id="rndr"] +== How do I Render These Docs? +This documentation is written in https://asciidoc.org/[AsciiDoc^] (with https://asciidoctor.org/[AsciiDoctor^] extensions). + +To re-render all docs (including <>): + +[source,bash, subs="attributes"] +---- +git clone {src_git} +cd {git_repo} +.githooks/pre-commit/01-docgen +---- + +[id="rndr_html"] +=== HTML +HTML output is re-rendered and included in git {src_dir}/.githooks/pre-commit/01-docgen[on each commit^] automatically (via https://github.com/gabyx/Githooks[`github:gabyx/Githooks`^]) +but can be re-rendered on-demand locally via: + +[source,bash, subs="attributes"] +---- +git clone {src_git} +cd {git_repo} +export orig_dir="$(pwd)" +cd {pkg_me} +asciidoctor -a ROOTDIR="${orig_dir}/" -o README.html README.adoc +---- + +[id="rndr_pdf"] +=== PDF +This documentation can be rendered to PDF via https://docs.asciidoctor.org/pdf-converter/latest/[`asciidoctor-pdf`^]. +It is not included in git automatically because binary files that change on each commit is not a good idea for git, +especially for a repo that gets cloned as part of a library inclusion in a module/package dependency system (like `gomod`). + +To render as PDF: + +[source,bash,subs="attributes"] +---- +git clone {src_git} +cd {git_repo} +export orig_dir="$(pwd)" +cd {pkg_me} +asciidoctor-pdf -a ROOTDIR="${orig_dir}/" -o README.pdf README.adoc +---- + [id="use"] == How do I Use SprigX? @@ -81,10 +134,10 @@ var ( ---- ==== -They can even be combined/used together. +They can even be combined/used together, [%collapsible] -.Like this. +.like this. ==== [source,go,subs="attributes"] ---- @@ -125,10 +178,8 @@ If a `