Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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)"
51 changes: 51 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
60 changes: 60 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -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$
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module github.com/gaissmai/ipam

go 1.26

require github.com/gaissmai/grammar v0.3.0
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
105 changes: 105 additions & 0 deletions internal/grammar/grammar.go
Original file line number Diff line number Diff line change
@@ -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)
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

// 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
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// 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
Comment thread
greptile-apps[bot] marked this conversation as resolved.
)
117 changes: 117 additions & 0 deletions internal/grammar/grammar_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}