summaryrefslogtreecommitdiff
path: root/utils
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-06-20 09:40:07 -0700
committerElizabeth Alexander Hunt <me@liz.coffee>2026-06-20 09:40:07 -0700
commitfb653292612eddca5b088ed88466c546d1597107 (patch)
tree900ed32e066812688858ed3bb15128c41bb78054 /utils
downloadpenguins.lan-fb653292612eddca5b088ed88466c546d1597107.tar.gz
penguins.lan-fb653292612eddca5b088ed88466c546d1597107.zip
Initial commit
Diffstat (limited to 'utils')
-rw-r--r--utils/net.go59
-rw-r--r--utils/net_test.go63
-rw-r--r--utils/random_id.go15
-rw-r--r--utils/text.go49
4 files changed, 186 insertions, 0 deletions
diff --git a/utils/net.go b/utils/net.go
new file mode 100644
index 0000000..76454bd
--- /dev/null
+++ b/utils/net.go
@@ -0,0 +1,59 @@
+package utils
+
+import (
+ "fmt"
+ "strings"
+)
+
+// Mac is a 48-bit hardware address.
+type Mac struct {
+ Addr [6]byte
+}
+
+// Format renders the canonical lowercase, zero-padded form aa:bb:cc:dd:ee:ff.
+func (m *Mac) Format() string {
+ return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x",
+ m.Addr[0], m.Addr[1], m.Addr[2], m.Addr[3], m.Addr[4], m.Addr[5])
+}
+
+// ParseMac reads a colon-separated hex MAC. It tolerates uppercase and the
+// zero-stripped octets macOS prints (e.g. "8:0:27:ab:cd:ef").
+func ParseMac(s string) (*Mac, error) {
+ mac := &Mac{}
+ t := strings.TrimSpace(s)
+ n, err := fmt.Sscanf(t, "%x:%x:%x:%x:%x:%x",
+ &mac.Addr[0], &mac.Addr[1], &mac.Addr[2], &mac.Addr[3], &mac.Addr[4], &mac.Addr[5])
+ if err != nil {
+ return nil, err
+ }
+ if n != 6 {
+ return nil, fmt.Errorf("invalid mac %q: parsed %d of 6 octets", s, n)
+ }
+ return mac, nil
+}
+
+// INet4 is an IPv4 address.
+type INet4 struct {
+ Addr [4]byte
+}
+
+// Format renders the dotted-decimal form a.b.c.d.
+func (n *INet4) Format() string {
+ return fmt.Sprintf("%d.%d.%d.%d", n.Addr[0], n.Addr[1], n.Addr[2], n.Addr[3])
+}
+
+// ParseINet4 reads a dotted-decimal IPv4 address. Out-of-range octets (>255)
+// overflow the byte and are rejected.
+func ParseINet4(s string) (*INet4, error) {
+ addr := &INet4{}
+ t := strings.TrimSpace(s)
+ n, err := fmt.Sscanf(t, "%d.%d.%d.%d",
+ &addr.Addr[0], &addr.Addr[1], &addr.Addr[2], &addr.Addr[3])
+ if err != nil {
+ return nil, err
+ }
+ if n != 4 {
+ return nil, fmt.Errorf("invalid ipv4 %q: parsed %d of 4 octets", s, n)
+ }
+ return addr, nil
+}
diff --git a/utils/net_test.go b/utils/net_test.go
new file mode 100644
index 0000000..27d991d
--- /dev/null
+++ b/utils/net_test.go
@@ -0,0 +1,63 @@
+package utils
+
+import "testing"
+
+func TestParseMacAndFormat(t *testing.T) {
+ // each input should parse and round-trip to the canonical form
+ canonical := map[string]string{
+ "aa:bb:cc:dd:ee:ff": "aa:bb:cc:dd:ee:ff", // already canonical
+ "AA:BB:CC:DD:EE:FF": "aa:bb:cc:dd:ee:ff", // uppercase
+ "8:0:27:ab:cd:ef": "08:00:27:ab:cd:ef", // macOS zero-stripped octets
+ " de:ad:be:ef:00:01 ": "de:ad:be:ef:00:01", // surrounding space
+ }
+ for in, want := range canonical {
+ mac, err := ParseMac(in)
+ if err != nil {
+ t.Errorf("ParseMac(%q) unexpected error: %v", in, err)
+ continue
+ }
+ if got := mac.Format(); got != want {
+ t.Errorf("ParseMac(%q).Format() = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestParseMacInvalid(t *testing.T) {
+ for _, in := range []string{
+ "", // empty
+ "aa:bb:cc:dd:ee", // too few octets
+ "hello", // not a mac
+ "zz:zz:zz:zz:zz:zz", // non-hex digits
+ } {
+ if _, err := ParseMac(in); err == nil {
+ t.Errorf("ParseMac(%q) = nil error, want error", in)
+ }
+ }
+}
+
+func TestParseINet4AndFormat(t *testing.T) {
+ for _, ip := range []string{"192.168.1.5", "10.0.0.2", "0.0.0.0", "255.255.255.255"} {
+ addr, err := ParseINet4(ip)
+ if err != nil {
+ t.Errorf("ParseINet4(%q) unexpected error: %v", ip, err)
+ continue
+ }
+ if got := addr.Format(); got != ip {
+ t.Errorf("ParseINet4(%q).Format() = %q, want %q", ip, got, ip)
+ }
+ }
+}
+
+func TestParseINet4Invalid(t *testing.T) {
+ for _, in := range []string{
+ "", // empty
+ "1.2.3", // too few octets
+ "192.168.1.256", // octet overflows a byte
+ "::1", // IPv6, not dotted-decimal
+ "hello", // garbage
+ } {
+ if _, err := ParseINet4(in); err == nil {
+ t.Errorf("ParseINet4(%q) = nil error, want error", in)
+ }
+ }
+}
diff --git a/utils/random_id.go b/utils/random_id.go
new file mode 100644
index 0000000..e6dc731
--- /dev/null
+++ b/utils/random_id.go
@@ -0,0 +1,15 @@
+package utils
+
+import (
+ "crypto/rand"
+ "fmt"
+)
+
+// RandomId returns a 128-bit hex id, handy for sessions, db rows, etc.
+func RandomId() string {
+ id := make([]byte, 16)
+ if _, err := rand.Read(id); err != nil {
+ panic(err)
+ }
+ return fmt.Sprintf("%x", id)
+}
diff --git a/utils/text.go b/utils/text.go
new file mode 100644
index 0000000..5a79ec9
--- /dev/null
+++ b/utils/text.go
@@ -0,0 +1,49 @@
+package utils
+
+import (
+ "fmt"
+ "math/rand"
+ "strings"
+ "time"
+)
+
+var nameAdjectives = []string{"waddly", "frosty", "noot", "sleepy", "tuxedo", "briny", "squeaky", "blizzard", "pebble", "slippy"}
+var nameNouns = []string{"penguin", "gentoo", "rockhopper", "emperor", "macaroni", "chinstrap", "skua", "krill", "iceberg", "fishstick"}
+
+func SuggestName() string {
+ return nameAdjectives[rand.Intn(len(nameAdjectives))] + "-" + nameNouns[rand.Intn(len(nameNouns))]
+}
+
+func Sanitize(raw string) string {
+ return strings.ReplaceAll(strings.TrimSpace(raw), "\n", " ")
+}
+
+func SanitizeName(raw string) string {
+ clean := strings.ToLower(Sanitize(raw))
+ clean = strings.ReplaceAll(clean, " ", "-")
+ if len(clean) > 24 {
+ clean = clean[:24]
+ }
+ return clean
+}
+
+func FormatSince(t time.Time) string {
+ d := time.Since(t)
+ switch {
+ case d < time.Minute && d.Seconds() < 20:
+ return "just now"
+ case d < time.Hour:
+ return fmt.Sprintf("%d minutes ago", int(d.Minutes()))
+ case d < 24*time.Hour:
+ return fmt.Sprintf("%d hours ago", int(d.Hours()))
+ default:
+ return fmt.Sprintf("%d days ago", int(d.Hours()/24))
+ }
+}
+
+func Truncate(s string, n int) string {
+ if len(s) > n {
+ return s[:n]
+ }
+ return s
+}