diff --git a/go.mod b/go.mod index e49c88f..3061af7 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.5 require ( github.com/coreos/go-systemd/v22 v22.5.0 github.com/google/uuid v1.6.0 + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba golang.org/x/sys v0.34.0 r00t2.io/sysutils v1.14.0 ) diff --git a/go.sum b/go.sum index 436d6d3..2eccd9f 100644 --- a/go.sum +++ b/go.sum @@ -5,9 +5,12 @@ github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYC github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +r00t2.io/sysutils v1.14.0 h1:Lrio3uPi9CuUdg+sg3WkVV1CK/qcOpV9GdFCGFG1KJs= r00t2.io/sysutils v1.14.0/go.mod h1:ZJ7gZxFVQ7QIokQ5fPZr7wl0XO5Iu+LqtE8j3ciRINw= diff --git a/stringsx/TODO b/stringsx/TODO new file mode 100644 index 0000000..f35a1bd --- /dev/null +++ b/stringsx/TODO @@ -0,0 +1,5 @@ +- Banner struct, with .Format(s string) method +-- draw border around multiline s +-- i have a version in python somewhere that does this, should dig that up + +- create bytesx package that duplicates the functions here? diff --git a/stringsx/consts.go b/stringsx/consts.go new file mode 100644 index 0000000..b7892a8 --- /dev/null +++ b/stringsx/consts.go @@ -0,0 +1,11 @@ +package stringsx + +const ( + // DefMaskStr is the string used as the default maskStr if left empty in [Redact]. + DefMaskStr string = "***" +) + +const ( + // DefIndentStr is the string used as the default indent if left empty in [Indent]. + DefIndentStr string = "\t" +) diff --git a/stringsx/doc.go b/stringsx/doc.go new file mode 100644 index 0000000..e75c545 --- /dev/null +++ b/stringsx/doc.go @@ -0,0 +1,4 @@ +/* +Package stringsx aims to extend functionality of the stdlib [strings] module. +*/ +package stringsx diff --git a/stringsx/funcs.go b/stringsx/funcs.go new file mode 100644 index 0000000..9ed1963 --- /dev/null +++ b/stringsx/funcs.go @@ -0,0 +1,249 @@ +package stringsx + +import ( + `strings` + `unicode` +) + +/* +Indent takes string s and indents it with string `indent` `level` times. + +If indent is an empty string, [DefIndentStr] will be used. + +If ws is true, lines consisting of only whitespace will be indented as well. +(To then trim any extraneous trailing space, you may want to use [TrimSpaceRight] +or [TrimLines].) + +If empty is true, lines with no content will be replaced with lines that purely +consist of (indent * level) (otherwise they will be left as empty lines). + +This function can also be used to prefix lines with arbitrary strings as well. +e.g: + + Indent("foo\nbar\nbaz\n", "# ", 1, false, false) + +would yield: + + # foo + # bar + # baz + + +thus allowing you to "comment out" multiple lines at once. +*/ +func Indent(s, indent string, level uint, ws, empty bool) (indented string) { + + var i string + var nl string + var endsNewline bool + var sb strings.Builder + var lineStripped string + + if indent == "" { + indent = DefIndentStr + } + + // This condition functionally won't do anything, so just return the input as-is. + if level == 0 { + indented = s + return + } + + i = strings.Repeat(indent, int(level)) + + // This condition functionally won't do anything, so just return the input as-is. + if s == "" { + if empty { + indented = i + } + return + } + + for line := range strings.Lines(s) { + lineStripped = strings.TrimSpace(line) + nl = getNewLine(line) + endsNewline = nl != "" + // fmt.Printf("%#v => %#v\n", line, lineStripped) + if lineStripped == "" { + // fmt.Printf("WS/EMPTY LINE (%#v) (ws %v, empty %v): \n", s, ws, empty) + if line != (lineStripped + nl) { + // whitespace-only line + if ws { + sb.WriteString(i) + } + } else { + // empty line + if empty { + sb.WriteString(i) + } + } + sb.WriteString(line) + continue + } + // non-empty/non-whitespace-only line. + sb.WriteString(i + line) + } + + // If it ends with a trailing newline and nothing after, strings.Lines() will skip the last (empty) line. + if endsNewline && empty { + nl = getNewLine(s) + sb.WriteString(i) + } + + indented = sb.String() + + return +} + +/* +Redact provides a "masked" version of string s (e.g. `my_terrible_password` -> `my****************rd`). + +maskStr is the character or sequence of characters +to repeat for every masked character of s. +If an empty string, the default [DefMaskStr] will be used. +(maskStr does not need to be a single character. +It is recommended to use a multi-char mask to help obfuscate a string's length.) + +leading specifies the number of leading characters of s to leave *unmasked*. +If 0, no leading characters will be unmasked. + +trailing specifies the number of trailing characters of s to leave *unmasked*. +if 0, no trailing characters will be unmasked. + +newlines, if true, will preserve newline characters - otherwise +they will be treated as regular characters. + +As a safety precaution, if: + + len(s) <= (leading + trailing) + +then the entire string will be *masked* and no unmasking will be performed. +*/ +func Redact(s, maskStr string, leading, trailing uint, newlines bool) (redacted string) { + + var nl string + var numMasked int + var sb strings.Builder + var endIdx int = int(leading) + + // This condition functionally won't do anything, so just return the input as-is. + if s == "" { + return + } + + if maskStr == "" { + maskStr = DefMaskStr + } + + if newlines { + for line := range strings.Lines(s) { + nl = getNewLine(line) + sb.WriteString( + Redact( + strings.TrimSuffix(line, nl), maskStr, leading, trailing, false, + ), + ) + sb.WriteString(nl) + } + } else { + if len(s) <= int(leading+trailing) { + redacted = strings.Repeat(maskStr, len(s)) + return + } + + if leading == 0 && trailing == 0 { + redacted = strings.Repeat(maskStr, len(s)) + return + } + + numMasked = len(s) - int(leading+trailing) + endIdx = endIdx + numMasked + + if leading > 0 { + sb.WriteString(s[:int(leading)]) + } + + sb.WriteString(strings.Repeat(maskStr, numMasked)) + + if trailing > 0 { + sb.WriteString(s[endIdx:]) + } + } + + redacted = sb.String() + + return +} + +/* +TrimLines is like [strings.TrimSpace] but operates on *each line* of s. +It is *NIX-newline (`\n`) vs. Windows-newline (`\r\n`) agnostic. +The first encountered linebreak (`\n` vs. `\r\n`) are assumed to be +the canonical linebreak for the rest of s. + +left, if true, performs a [TrimSpaceLeft] on each line (retaining the newline). + +right, if true, performs a [TrimSpaceRight] on each line (retaining the newline). +*/ +func TrimLines(s string, left, right bool) (trimmed string) { + + var sl string + var nl string + var sb strings.Builder + + // These conditions functionally won't do anything, so just return the input as-is. + if s == "" { + return + } + if !left && !right { + trimmed = s + return + } + + for line := range strings.Lines(s) { + nl = getNewLine(line) + sl = strings.TrimSuffix(line, nl) + if left && right { + sl = strings.TrimSpace(sl) + } else if left { + sl = TrimSpaceLeft(sl) + } else if right { + sl = TrimSpaceRight(sl) + } + sb.WriteString(sl + nl) + } + + trimmed = sb.String() + + return +} + +// TrimSpaceLeft is like [strings.TrimSpace] but only removes leading whitespace from string s. +func TrimSpaceLeft(s string) (trimmed string) { + + trimmed = strings.TrimLeftFunc(s, unicode.IsSpace) + + return +} + +/* +TrimSpaceRight is like [strings.TrimSpace] but only removes trailing whitespace from string s. +*/ +func TrimSpaceRight(s string) (trimmed string) { + + trimmed = strings.TrimRightFunc(s, unicode.IsSpace) + + return +} + +// getNewLine is too unpredictable to be used outside of this package so it isn't exported. +func getNewLine(s string) (nl string) { + + if strings.HasSuffix(s, "\r\n") { + nl = "\r\n" + } else if strings.HasSuffix(s, "\n") { + nl = "\n" + } + + return +} diff --git a/stringsx/funcs_test.go b/stringsx/funcs_test.go new file mode 100644 index 0000000..c3da753 --- /dev/null +++ b/stringsx/funcs_test.go @@ -0,0 +1,451 @@ +package stringsx + +import ( + "testing" +) + +type ( + testIndentSet struct { + name string + orig string + indent string + lvl uint + ws bool + empty bool + tgt string + } + testRedactSet struct { + name string + orig string + leading uint + trailing uint + tgt string + newline bool + mask string // defaults to DefMaskStr. + } + testTrimLinesSet struct { + name string + orig string + left bool + right bool + tgt string + } + testTrimSet struct { + name string + orig string + tgt string + } +) + +func TestIndent(t *testing.T) { + + var out string + var tests []testIndentSet = []testIndentSet{ + testIndentSet{ + name: "standard, no trailing newline", + orig: "foo\nbar\nbaz", + indent: "", + lvl: 1, + ws: false, + empty: false, + tgt: "\tfoo\n\tbar\n\tbaz", + }, + testIndentSet{ + name: "standard, trailing newline", + orig: "foo\nbar\nbaz\n", + indent: "", + lvl: 1, + ws: false, + empty: false, + tgt: "\tfoo\n\tbar\n\tbaz\n", + }, + testIndentSet{ + name: "standard, trailing newline with empty", + orig: "foo\nbar\nbaz\n", + indent: "", + lvl: 1, + ws: false, + empty: true, + tgt: "\tfoo\n\tbar\n\tbaz\n\t", + }, + testIndentSet{ + name: "standard, trailing newline with ws", + orig: "foo\nbar\nbaz\n", + indent: "", + lvl: 1, + ws: true, + empty: false, + tgt: "\tfoo\n\tbar\n\tbaz\n", + }, + testIndentSet{ + name: "standard, trailing newline with ws and empty", + orig: "foo\nbar\nbaz\n", + indent: "", + lvl: 1, + ws: true, + empty: true, + tgt: "\tfoo\n\tbar\n\tbaz\n\t", + }, + testIndentSet{ + name: "standard, trailing ws newline with empty", + orig: "foo\nbar\nbaz\n ", + indent: "", + lvl: 1, + ws: false, + empty: true, + tgt: "\tfoo\n\tbar\n\tbaz\n ", + }, + testIndentSet{ + name: "standard, trailing ws newline with ws", + orig: "foo\nbar\nbaz\n ", + indent: "", + lvl: 1, + ws: true, + empty: false, + tgt: "\tfoo\n\tbar\n\tbaz\n\t ", + }, + testIndentSet{ + name: "standard, trailing ws newline with ws and empty", + orig: "foo\nbar\nbaz\n \n", + indent: "", + lvl: 1, + ws: true, + empty: true, + tgt: "\tfoo\n\tbar\n\tbaz\n\t \n\t", + }, + testIndentSet{ + name: "comment", + orig: "foo\nbar\nbaz", + indent: "# ", + lvl: 1, + ws: false, + empty: false, + tgt: "# foo\n# bar\n# baz", + }, + } + + for idx, ts := range tests { + out = Indent(ts.orig, ts.indent, ts.lvl, ts.ws, ts.empty) + if out == ts.tgt { + t.Logf("[%d] OK (%s): %#v: got %#v", idx, ts.name, ts.orig, out) + } else { + t.Errorf( + "[%d] FAIL (%s): %#v (len %d):\n"+ + "\t\t\texpected (len %d): %#v\n"+ + "\t\t\tgot (len %d): %#v\n"+ + "\t\t%#v", + idx, ts.name, ts.orig, len(ts.orig), + len(ts.tgt), ts.tgt, + len(out), out, + ts, + ) + } + } + +} + +func TestRedact(t *testing.T) { + + var out string + var tests []testRedactSet = []testRedactSet{ + testRedactSet{ + name: "empty in, empty out", + orig: "", + leading: 0, + trailing: 0, + tgt: "", + }, + testRedactSet{ + name: "standard", + orig: "password", + leading: 0, + trailing: 0, + tgt: "************************", + }, + testRedactSet{ + name: "standard with newline", + orig: "pass\nword", + leading: 0, + trailing: 0, + tgt: "************\n************", + newline: true, + }, + testRedactSet{ + name: "standard with Windows newline", + orig: "pass\r\nword", + leading: 0, + trailing: 0, + tgt: "************\r\n************", + newline: true, + }, + testRedactSet{ + name: "standard with newline without newlines", + orig: "pass\nword", + leading: 0, + trailing: 0, + tgt: "***************************", + }, + testRedactSet{ + name: "single leading", + orig: "password", + leading: 1, + trailing: 0, + tgt: "p*********************", + }, + testRedactSet{ + name: "single trailing", + orig: "password", + leading: 0, + trailing: 1, + tgt: "*********************d", + }, + testRedactSet{ + name: "three leading", + orig: "password", + leading: 3, + trailing: 0, + tgt: "pas***************", + }, + testRedactSet{ + name: "three trailing", + orig: "password", + leading: 0, + trailing: 3, + tgt: "***************ord", + }, + testRedactSet{ + name: "three leading and trailing", + orig: "password", + leading: 3, + trailing: 3, + tgt: "pas******ord", + }, + testRedactSet{ + name: "unmask overflow leading", + orig: "password", + leading: 5, + trailing: 4, + tgt: "************************", + }, + testRedactSet{ + name: "unmask overflow trailing", + orig: "password", + leading: 4, + trailing: 5, + tgt: "************************", + }, + testRedactSet{ + name: "single mask", + orig: "password", + leading: 0, + trailing: 0, + tgt: "********", + mask: "*", + }, + testRedactSet{ + name: "standard trailing newline with newlines", + orig: "password\n", + leading: 0, + trailing: 0, + tgt: "************************\n", + newline: true, + }, + testRedactSet{ + name: "standard trailing newline without newlines", + orig: "password\n", + leading: 0, + trailing: 0, + tgt: "***************************", + }, + } + + for idx, ts := range tests { + out = Redact(ts.orig, ts.mask, ts.leading, ts.trailing, ts.newline) + if out == ts.tgt { + t.Logf("[%d] OK (%s): %#v: got %#v", idx, ts.name, ts.orig, out) + } else { + t.Errorf( + "[%d] FAIL (%s): %#v (len %d):\n"+ + "\t\t\texpected (len %d): %#v\n"+ + "\t\t\tgot (len %d): %#v\n"+ + "\t\t%#v", + idx, ts.name, ts.orig, len(ts.orig), + len(ts.tgt), ts.tgt, + len(out), out, + ts, + ) + } + } +} + +func TestTrimLines(t *testing.T) { + + var out string + var tests []testTrimLinesSet = []testTrimLinesSet{ + testTrimLinesSet{ + name: "none", + orig: " foo \n bar \n baz ", + left: false, + right: false, + tgt: " foo \n bar \n baz ", + }, + testTrimLinesSet{ + name: "standard", + orig: " foo \n bar \n baz ", + left: true, + right: true, + tgt: "foo\nbar\nbaz", + }, + testTrimLinesSet{ + name: "left only", + orig: " foo \n bar \n baz ", + left: true, + right: false, + tgt: "foo \nbar \nbaz ", + }, + testTrimLinesSet{ + name: "right only", + orig: " foo \n bar \n baz ", + left: false, + right: true, + tgt: " foo\n bar\n baz", + }, + testTrimLinesSet{ + name: "standard, trailing newline", + orig: " foo \n bar \n baz \n", + left: true, + right: true, + tgt: "foo\nbar\nbaz\n", + }, + testTrimLinesSet{ + name: "left only, trailing newline", + orig: " foo \n bar \n baz \n", + left: true, + right: false, + tgt: "foo \nbar \nbaz \n", + }, + testTrimLinesSet{ + name: "right only, trailing newline", + orig: " foo \n bar \n baz \n", + left: false, + right: true, + tgt: " foo\n bar\n baz\n", + }, + // Since there's no "non-space" boundary, both of these condition tests do the same thing. + testTrimLinesSet{ + name: "left only, trailing newline and ws", + orig: " foo \n bar \n baz \n ", + left: true, + right: false, + tgt: "foo \nbar \nbaz \n", + }, + testTrimLinesSet{ + name: "right only, trailing newline and ws", + orig: " foo \n bar \n baz \n ", + left: false, + right: true, + tgt: " foo\n bar\n baz\n", + }, + } + + for idx, ts := range tests { + out = TrimLines(ts.orig, ts.left, ts.right) + if out == ts.tgt { + t.Logf("[%d] OK (%s): %#v: got %#v", idx, ts.name, ts.orig, out) + } else { + t.Errorf( + "[%d] FAIL (%s): %#v (len %d):\n"+ + "\t\t\texpected (len %d): %#v\n"+ + "\t\t\tgot (len %d): %#v\n"+ + "\t\t%#v", + idx, ts.name, ts.orig, len(ts.orig), + len(ts.tgt), ts.tgt, + len(out), out, + ts, + ) + } + } + +} + +func TestTrimSpaceLeft(t *testing.T) { + + var out string + var tests []testTrimSet = []testTrimSet{ + testTrimSet{ + name: "standard", + orig: " foo ", + tgt: "foo ", + }, + testTrimSet{ + name: "tabs", + orig: "\t\tfoo\t\t", + tgt: "foo\t\t", + }, + testTrimSet{ + name: "newlines", + orig: "\n\nfoo\n\n", + tgt: "foo\n\n", + }, + } + + for idx, ts := range tests { + out = TrimSpaceLeft(ts.orig) + if out == ts.tgt { + t.Logf("[%d] OK (%s): %#v: got %#v", idx, ts.name, ts.orig, out) + } else { + t.Errorf( + "[%d] FAIL (%s): %#v (len %d):\n"+ + "\t\t\texpected (len %d): %#v\n"+ + "\t\t\tgot (len %d): %#v\n"+ + "\t\t%#v", + idx, ts.name, ts.orig, len(ts.orig), + len(ts.tgt), ts.tgt, + len(out), out, + ts, + ) + } + } + +} + +func TestTrimSpaceRight(t *testing.T) { + + var out string + var tests []testTrimSet = []testTrimSet{ + testTrimSet{ + name: "standard", + orig: " foo ", + tgt: " foo", + }, + testTrimSet{ + name: "tabs", + orig: "\t\tfoo\t\t", + tgt: "\t\tfoo", + }, + testTrimSet{ + name: "newlines", + orig: "\n\nfoo\n\n", + tgt: "\n\nfoo", + }, + } + + for idx, ts := range tests { + out = TrimSpaceRight(ts.orig) + if out == ts.tgt { + t.Logf("[%d] OK (%s): %#v: got %#v", idx, ts.name, ts.orig, out) + } else { + t.Errorf( + "[%d] FAIL (%s): %#v (len %d):\n"+ + "\t\t\texpected (len %d): %#v\n"+ + "\t\t\tgot (len %d): %#v\n"+ + "\t\t%#v", + idx, ts.name, ts.orig, len(ts.orig), + len(ts.tgt), ts.tgt, + len(out), out, + ts, + ) + } + } + +}