diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..adf9bb1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "04:00" + timezone: "UTC" + open-pull-requests-limit: 5 + labels: ["dependencies", "gomod"] + commit-message: + prefix: "chore(deps)" + groups: + minor-and-patch: + update-types: ["minor", "patch"] + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "04:00" + timezone: "UTC" + open-pull-requests-limit: 5 + labels: ["dependencies", "github-actions"] + commit-message: + prefix: "chore(actions)" diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..2bf6ac2 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,51 @@ +name: CI +on: + push: + branches: ["main", "devel"] + paths: &paths + - 'go.mod' + - '**.go' + - '**.yml' + - '**.yaml' + pull_request: + branches: ["main", "devel"] + paths: *paths + +# Best Practice: Restrict global permissions to minimum required (Read-Only) +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + # Disable credential persisting for enhanced security + persist-credentials: false + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true # Enable build caching for faster test execution + + - name: Run Tests + run: go test -v ./... + + linting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: false # golangci-lint-action handles its own caching + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + # Use 'latest' to prevent version mismatch with go.mod + version: latest diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..913974f --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,60 @@ +version: '2' +run: + timeout: 5m +linters: + enable: + - decorder + - errorlint + - gocyclo + - govet + - gosec + - gosmopolitan + - intrange + - misspell + - predeclared + - reassign + - staticcheck + - unconvert + - unparam + - usestdlibvars + - wastedassign + - copyloopvar + - tparallel + - gochecknoglobals + - nolintlint + - modernize + - gocritic + settings: + govet: + enable: + - shadow + gocyclo: + min-complexity: 30 + exclusions: + generated: lax + rules: + - linters: + - gochecknoglobals + - gosec + - gocyclo + path: _test\.go + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ + +formatters: + enable: + - gofmt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a155276 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/gaissmai/ipam + +go 1.26 + +require github.com/gaissmai/grammar v0.3.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0566ad7 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/gaissmai/grammar v0.3.0 h1:O90o3Fe5Vaw/3044pip3UEekN4RSl8mExKMKv2WuuGM= +github.com/gaissmai/grammar v0.3.0/go.mod h1:iAnAryYv9vmVajL9t7zKDnWjeySd8LrTacEwOMbnlvk= diff --git a/internal/grammar/grammar.go b/internal/grammar/grammar.go new file mode 100644 index 0000000..d6fa700 --- /dev/null +++ b/internal/grammar/grammar.go @@ -0,0 +1,105 @@ +// Package grammar defines patterns and rules for IPAM domain specific language. +package grammar + +import ( + "regexp" + + "github.com/gaissmai/grammar" +) + +// g is ready to use after init(). +// +//nolint:gochecknoglobals +var g *grammar.Grammar + +// init compiles the grammar rules. +func init() { + g = grammar.New("IPAM") + + for name, rawPattern := range AllRules() { + if err := g.Add(name, rawPattern); err != nil { + panic(err) + } + } + + if err := g.Compile(); err != nil { + panic(err) + } +} + +// MustRx returns the regexp for rule from the underlying grammar. +func MustRx(name string) *regexp.Regexp { + rx, err := g.Rx(name) + if err != nil { + panic(err) + } + + return rx +} + +// AllRules to build the IPAM tokens. +func AllRules() map[string]string { + return map[string]string{ + "label": label, + "word": word, + "cswords": cswords, + "fqdn": fqdn, + "ident": ident, + "comment": comment, + "scoping": scoping, + "ip": ip, + "ipv4": ipv4, + "ipv6": ipv6, + } +} + +// ########################################################################### +// IPAM GRAMMAR +// slimmed down version, just some helper regexps for the lexer state machine +// ########################################################################### + +const ( + + // # comment til EOL + comment = `^ # .* $` + + scoping = `^ --- -* $` + + // label is a field of a FQDN + label = `(?: [- \w]+ )` // hyphen and word chars, the rest is done by the parser + + // word + word = `\w [-\w]*` + + // comma-separated words + cswords = `^ ${word} (?: , ${word} )* $` + + // full qualified domain name, com com. www.google.com. *.foo.bar + // the length restriction parsing etc. is done by the parser + // IDNA not supported, just plain ASCII + fqdn = `^ + (?: + (?: \*\. )? // alias can start with wildcard followed by dot + (?: ${label} \. )+ // one or more (label followed by dot) + ${label}? [a-zA-Z]+ // TLD ends with at least one ASCII letter + \.? // last dot optional + $)` + + // keywords, attributes and flags are uppercase + ident = `^ (?: [A-Z] [A-Z0-9_]+ )` // missing $ is intended + + // simple and fast patterns for lexing, true parsing with netip.ParseAddr() + ip = `^ (?: ${ipv4} | ${ipv6} ) ` // missing $ is intended + + ipv4 = `^ (?: + \d{1,3} \. + \d{1,3} \. + \d{1,3} \. + \d{1,3} + )` // missing $ is intended + + ipv6 = `^ (?: // xdigits and colons, no dot + [[:xdigit:] :]+ : + [[:xdigit:] :]* + )` // missing $ is intended +) diff --git a/internal/grammar/grammar_test.go b/internal/grammar/grammar_test.go new file mode 100644 index 0000000..4bcbda9 --- /dev/null +++ b/internal/grammar/grammar_test.go @@ -0,0 +1,117 @@ +package grammar_test + +import ( + "testing" + + g "github.com/gaissmai/ipam/internal/grammar" +) + +func TestGrammar(t *testing.T) { + t.Parallel() + + for name := range g.AllRules() { + rx := g.MustRx(name) + t.Log(name, rx) + } +} + +func TestGrammarRules(t *testing.T) { + t.Parallel() + + tt := []struct { + rule string + input string + want bool + }{ + // ── word ──────────────────────────────────────────────────────────── + {rule: "word", input: "hello", want: true}, + {rule: "word", input: "with-hyphen", want: true}, + {rule: "word", input: "word123", want: true}, + {rule: "word", input: "_underscore", want: true}, + {rule: "word", input: "", want: false}, + + // ── cswords ───────────────────────────────────────────────────────── + {rule: "cswords", input: "single", want: true}, + {rule: "cswords", input: "std,open", want: true}, + {rule: "cswords", input: "a,b-c,d", want: true}, + {rule: "cswords", input: ",leading,comma", want: false}, + {rule: "cswords", input: "trailing,comma,", want: false}, + {rule: "cswords", input: "a,,b", want: false}, // double comma + {rule: "cswords", input: "-starts,with,hyphen", want: false}, + {rule: "cswords", input: "with space", want: false}, // spaces not allowed + + // ── comment ───────────────────────────────────────────────────────── + {rule: "comment", input: "# this is a comment", want: true}, + {rule: "comment", input: "#", want: true}, + {rule: "comment", input: "# ", want: true}, + {rule: "comment", input: "not a comment", want: false}, + {rule: "comment", input: "", want: false}, + {rule: "comment", input: " # indented", want: false}, // ^ anchors at start + + // ── scoping ───────────────────────────────────────────────────────── + {rule: "scoping", input: "---", want: true}, + {rule: "scoping", input: "----", want: true}, + {rule: "scoping", input: "----------", want: true}, + {rule: "scoping", input: "--", want: false}, // minimum three dashes + {rule: "scoping", input: "- -", want: false}, // no spaces + {rule: "scoping", input: "--- x", want: false}, // nothing after dashes + + // ── fqdn ──────────────────────────────────────────────────────────── + {rule: "fqdn", input: "www.example.com", want: true}, + {rule: "fqdn", input: "www.example.com.", want: true}, // trailing dot allowed + {rule: "fqdn", input: "*.foo.bar", want: true}, // wildcard prefix + {rule: "fqdn", input: "example.com", want: true}, + {rule: "fqdn", input: "a.b.c.d.e.tld", want: true}, + {rule: "fqdn", input: "localhost", want: false}, // bare hostname, no dot + {rule: "fqdn", input: "192.168.1.1", want: false}, // TLD must end with letter + {rule: "fqdn", input: "", want: false}, + {rule: "fqdn", input: "foo bar.com", want: false}, // spaces not allowed in label + + // ── ident (no trailing $, anchored only at start) ─────────────────── + {rule: "ident", input: "VLAN", want: true}, + {rule: "ident", input: "VRF", want: true}, + {rule: "ident", input: "STATUS_OK", want: true}, + {rule: "ident", input: "VLAN10", want: true}, + {rule: "ident", input: "A9", want: true}, + {rule: "ident", input: "A", want: false}, // [A-Z0-9_]+ needs ≥1 more char + {rule: "ident", input: "lowercase", want: false}, + {rule: "ident", input: "Mixed", want: false}, + {rule: "ident", input: "1NVALID", want: false}, // must start with A-Z + + // ── ip (no trailing $, anchored only at start) ─────────────────────── + {rule: "ip", input: "192.168.1.1", want: true}, + {rule: "ip", input: "192.168.1.1/24", want: true}, // prefix is fine, no trailing $ + {rule: "ip", input: "10.0.0.1", want: true}, + {rule: "ip", input: "2001:db8::1", want: true}, + {rule: "ip", input: "::1", want: true}, + {rule: "ip", input: "not-an-ip", want: false}, + {rule: "ip", input: "", want: false}, + + // ── ipv4 (no trailing $) ───────────────────────────────────────────── + {rule: "ipv4", input: "192.168.1.1", want: true}, + {rule: "ipv4", input: "0.0.0.0", want: true}, + {rule: "ipv4", input: "255.255.255.255", want: true}, + {rule: "ipv4", input: "192.168.1.1/24", want: true}, // CIDR — no trailing $ intended + {rule: "ipv4", input: "2001:db8::1", want: false}, // IPv6 must not match ipv4 + {rule: "ipv4", input: "1.2.3", want: false}, // only three octets + {rule: "ipv4", input: "not-an-ip", want: false}, + + // ── ipv6 (no trailing $) ───────────────────────────────────────────── + {rule: "ipv6", input: "2001:db8::1 foo", want: true}, + {rule: "ipv6", input: "::1", want: true}, + {rule: "ipv6", input: "fe80::1", want: true}, + {rule: "ipv6", input: "2001:db8::1/64", want: true}, // prefix — no trailing $ intended + {rule: "ipv6", input: "192.168.1.1", want: false}, // pure dotted-decimal has no colon + {rule: "ipv6", input: "not-an-ip", want: false}, + } + + for _, tc := range tt { + t.Run(tc.rule+"/"+tc.input, func(t *testing.T) { + t.Parallel() + got := g.MustRx(tc.rule).MatchString(tc.input) + if got != tc.want { + t.Fatalf("rule=%q input=%q: got %v, want %v", tc.rule, tc.input, got, tc.want) + } + }) + } +}