diff --git a/telemetry.go b/telemetry.go index ce4e487..2ac18b9 100644 --- a/telemetry.go +++ b/telemetry.go @@ -34,6 +34,7 @@ import ( "context" "net/http" "os" + "runtime/debug" "strconv" "strings" "sync" @@ -69,6 +70,11 @@ func init() { // immediately. It never blocks, never panics, and never reports errors. // Invalid inputs, disabled state, and network failures are silently dropped. // +// Version strings are validated against a SemVer-ish shape that mirrors the +// receiver. An optional leading "v" or "V" is accepted and stripped before +// transmission so that callers can pass either "v1.2.3" or "1.2.3"; the +// wire form is always the unprefixed canonical version. +// // Call once at program startup. Calling repeatedly will send repeated pings; // the server is responsible for deduplication. func Send(project, version string) { @@ -81,15 +87,60 @@ func Send(project, version string) { if !validProject(project) || !validVersion(version) { return } + canonical := normalizeVersion(version) inflight.Add(1) go func() { defer inflight.Done() defer func() { _ = recover() }() - dispatch(project, version) + dispatch(project, canonical) }() } +// SendForModule is the recommended call form for Go libraries: it resolves +// the version automatically from Go's build info for the given module path +// so consumers do not need to maintain a hand-bumped version constant in +// source. Behaviour and contract are otherwise identical to [Send]. +// +// Resolution order: +// +// 1. debug.ReadBuildInfo Deps entry for modulePath (authoritative when the +// library is consumed via go.mod); +// 2. debug.ReadBuildInfo Main when the library is itself the main module +// (e.g. running its own tests or examples); +// 3. fallback parameter, used only when build info is unavailable or +// unhelpful (replace directives, detached `go run`, ldflag override). +// +// Any leading "v" reported by build info is stripped to match the canonical +// wire form. Empty / "(devel)" build versions are skipped in favour of the +// next resolution source. Typical usage: +// +// telemetry.SendForModule("my-tool", "github.com/me/my-tool", "0.0.0-dev") +func SendForModule(project, modulePath, fallback string) { + Send(project, ResolveModuleVersion(modulePath, fallback)) +} + +// ResolveModuleVersion implements the version resolution used by +// SendForModule. Exposed for callers that need to format the resolved +// version (e.g. logging) without firing a ping. +func ResolveModuleVersion(modulePath, fallback string) string { + if info, ok := debug.ReadBuildInfo(); ok { + for _, d := range info.Deps { + if d != nil && d.Path == modulePath && isUsableBuildVersion(d.Version) { + return strings.TrimPrefix(d.Version, "v") + } + } + if info.Main.Path == modulePath && isUsableBuildVersion(info.Main.Version) { + return strings.TrimPrefix(info.Main.Version, "v") + } + } + return fallback +} + +func isUsableBuildVersion(v string) bool { + return v != "" && v != "(devel)" +} + // Disable suppresses all subsequent Send calls in this process. // Idempotent and safe to call from any goroutine. func Disable() { @@ -170,23 +221,94 @@ func validProject(p string) bool { return true } +// validVersion accepts SemVer-ish version strings with an optional leading +// "v"/"V" prefix. Acceptable shape (after stripping the leading v): +// +// MAJOR[.MINOR[.PATCH]] ("-"prerelease)? ("+"build)? +// +// where MAJOR/MINOR/PATCH are ASCII digit sequences and the prerelease/build +// payloads are non-empty runs of [0-9A-Za-z.-]. This intentionally mirrors +// the receiver's version regex so junk like "dev" or "git-2026-05-22" never +// leaves the client (where it would only be rejected with HTTP 400 anyway). func validVersion(v string) bool { n := len(v) if n == 0 || n > maxVersionLen { return false } - for i := range n { - c := v[i] - switch { - case c >= 'A' && c <= 'Z', - c >= 'a' && c <= 'z', - c >= '0' && c <= '9', - c == '.', c == '+', c == '_', c == '-': - default: + if v[0] == 'v' || v[0] == 'V' { + v = v[1:] + } + if len(v) == 0 { + return false + } + return checkSemverShape(v) +} + +// normalizeVersion strips an optional leading "v"/"V" so the on-the-wire +// version matches the form stored server-side by the version refresher cron +// (which also strips the leading v from release tags). Callers may pass +// either "v1.2.3" or "1.2.3" — only the unprefixed form is transmitted. +func normalizeVersion(v string) string { + if len(v) > 0 && (v[0] == 'v' || v[0] == 'V') { + return v[1:] + } + return v +} + +func checkSemverShape(s string) bool { + i := 0 + if !readDigitRun(s, &i) { + return false + } + for groups := 0; groups < 2 && i < len(s) && s[i] == '.'; groups++ { + i++ + if !readDigitRun(s, &i) { return false } } - return true + if i < len(s) && s[i] == '-' { + i++ + if !readIdentRun(s, &i, '+') { + return false + } + } + if i < len(s) && s[i] == '+' { + i++ + if !readIdentRun(s, &i, 0) { + return false + } + } + return i == len(s) +} + +func readDigitRun(s string, i *int) bool { + start := *i + for *i < len(s) && s[*i] >= '0' && s[*i] <= '9' { + *i++ + } + return *i > start +} + +// readIdentRun consumes [0-9A-Za-z.-] until end-of-string or until `stop` +// is hit (stop=0 disables the early-stop check). Returns false if no +// characters were consumed (i.e. empty payload). +func readIdentRun(s string, i *int, stop byte) bool { + start := *i + for *i < len(s) { + c := s[*i] + if stop != 0 && c == stop { + break + } + valid := (c >= '0' && c <= '9') || + (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + c == '.' || c == '-' + if !valid { + return false + } + *i++ + } + return *i > start } func isDisabledByEnv(project string) bool { diff --git a/telemetry_test.go b/telemetry_test.go index 49ea9d6..99a2607 100644 --- a/telemetry_test.go +++ b/telemetry_test.go @@ -169,6 +169,98 @@ func TestSend_AcceptsCommonVersionShapes(t *testing.T) { } } +func TestSend_RejectsNonSemverJunk(t *testing.T) { + resetState(t) + srv := newCaptureServer(t, http.StatusNoContent) + + // These match the old permissive regex but the receiver always + // rejected them with HTTP 400. The library now drops them client-side + // so callers like GMP's unset `appVersion = "dev"` produce zero traffic + // rather than a stream of HTTP 400s. + junk := []string{"dev", "next", "latest", "snapshot", "git-2026-05-22", "alpha", "abc.def"} + for _, v := range junk { + Send("tool", v) + } + Wait(500 * time.Millisecond) + + if srv.Hits() != 0 { + t.Fatalf("expected 0 hits for non-semver junk, got %d", srv.Hits()) + } +} + +func TestSend_NormalizesLeadingV(t *testing.T) { + resetState(t) + srv := newCaptureServer(t, http.StatusNoContent) + + Send("tool", "v1.2.3") + Send("tool", "V9.0.0") + Wait(2 * time.Second) + + if srv.Hits() != 2 { + t.Fatalf("expected 2 hits, got %d", srv.Hits()) + } + + // Concurrent goroutines may arrive in any order, so compare the SET. + srv.mu.Lock() + defer srv.mu.Unlock() + got := make(map[string]struct{}, len(srv.requests)) + for _, req := range srv.requests { + if strings.HasPrefix(req.Version, "v") || strings.HasPrefix(req.Version, "V") { + t.Fatalf("wire version still has leading v: %q", req.Version) + } + got[req.Version] = struct{}{} + } + for _, want := range []string{"1.2.3", "9.0.0"} { + if _, ok := got[want]; !ok { + t.Fatalf("missing normalized %q in wire versions %v", want, got) + } + } +} + +func TestResolveModuleVersion_FallbackWhenUnknownModule(t *testing.T) { + // "nonexistent.example/none" will never appear in debug.BuildInfo of the + // test binary, so resolution must fall through to the fallback parameter. + got := ResolveModuleVersion("nonexistent.example/none", "0.0.0-fallback") + if got != "0.0.0-fallback" { + t.Fatalf("expected fallback, got %q", got) + } +} + +func TestResolveModuleVersion_StripsLeadingVFromBuildInfo(t *testing.T) { + // During `go test`, the Main module's path is the package under test. + // Its build info Version is typically "(devel)" — which we treat as + // unusable and fall back. Verify that branch: pass the current module + // path; expect the fallback rather than a literal "(devel)" leak. + got := ResolveModuleVersion("github.com/lukaszraczylo/oss-telemetry", "0.0.0-fallback") + if got == "(devel)" { + t.Fatalf("resolved to (devel) — should fall back to caller-supplied value") + } + if got == "v0.0.0-fallback" { + t.Fatalf("leading v not stripped from fallback path: %q", got) + } +} + +func TestSendForModule_FallbackPath(t *testing.T) { + resetState(t) + srv := newCaptureServer(t, http.StatusNoContent) + + // Unknown modulePath → resolver returns the fallback, which validVersion + // must accept and Send forward. + SendForModule("tool", "nonexistent.example/none", "1.2.3") + Wait(2 * time.Second) + + if srv.Hits() != 1 { + t.Fatalf("expected 1 hit, got %d", srv.Hits()) + } + got, _ := srv.Last() + if got.Version != "1.2.3" { + t.Fatalf("expected wire version 1.2.3, got %q", got.Version) + } + if got.Project != "tool" { + t.Fatalf("expected project tool, got %q", got.Project) + } +} + func TestDisable_StopsSubsequentSends(t *testing.T) { resetState(t) srv := newCaptureServer(t, http.StatusNoContent) @@ -329,9 +421,21 @@ func TestValidationHelpers(t *testing.T) { {"1.2.3", true}, {"1.4.0-beta1", true}, {"v1.0.0", true}, + {"V1.0.0", true}, {"1.0.0+meta", true}, + {"1.0.0-rc.1+build.7", true}, + {"1", true}, + {"1.2", true}, {"", false}, {"has space", false}, + {"dev", false}, + {"next", false}, + {"v", false}, + {"v.1.2", false}, + {"1.2.3.4", false}, + {"-1.2.3", false}, + {"1.2.3-", false}, + {"1.2.3+", false}, {strings.Repeat("1", 32), true}, {strings.Repeat("1", 33), false}, }