// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later // Package compat decides whether two Echolot builds should talk to each other. // // Versions are SemVer. What is actually being checked, though, is not "is this release recent" // but "does this peer speak a wire protocol and schema I understand" — the version is a proxy for // that, and the proxy only holds because we bump the breaking axis when the contract changes. // So the bounds here are set at *breaking boundaries*, not at every release: a patch bump must // never strand a fleet, and the range must not need editing to ship a bugfix. // // The one rule that shapes the rest: refusing a peer must still tell it why. A client that cannot // reach the profile endpoint cannot learn what version it should be, so it has nothing to show its // user but a network error. GET /v1/profile is therefore always reachable, whatever the range says. package compat import ( "fmt" "strconv" "strings" ) // Version is a parsed SemVer. Build metadata is discarded (it is explicitly not part of // precedence); pre-release is kept and compared, because "1.0.0-rc1" must sort below "1.0.0". type Version struct { Major, Minor, Patch int Pre string } // Parse accepts "1.2.3", "v1.2.3" and our namespaced release tags ("server-v1.2.3"), because // those are the forms that actually reach this code: a tag, a --version output, and an HTTP // header written by three different pieces of software. func Parse(s string) (Version, bool) { s = strings.TrimSpace(s) // Strip a tag prefix ending in "v" ("v1.2.3", "server-v1.2.3"). Guarded on the prefix having // no digits so a pre-release identifier that happens to contain a "v" is left alone. if i := strings.LastIndexByte(s, 'v'); i >= 0 && i+1 < len(s) && s[i+1] >= '0' && s[i+1] <= '9' && !strings.ContainsAny(s[:i], "0123456789") { s = s[i+1:] } if plus := strings.IndexByte(s, '+'); plus >= 0 { s = s[:plus] } var pre string if dash := strings.IndexByte(s, '-'); dash >= 0 { pre, s = s[dash+1:], s[:dash] } parts := strings.Split(s, ".") if len(parts) != 3 { return Version{}, false } out := Version{Pre: pre} for i, p := range parts { n, err := strconv.Atoi(p) if err != nil || n < 0 { return Version{}, false } switch i { case 0: out.Major = n case 1: out.Minor = n case 2: out.Patch = n } } return out, true } func (v Version) String() string { s := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) if v.Pre != "" { s += "-" + v.Pre } return s } // Compare returns -1, 0 or 1. A pre-release sorts below the same version without one (SemVer §11); // two pre-releases compare lexically, which is close enough for the identifiers we use. func (v Version) Compare(o Version) int { for _, d := range []int{v.Major - o.Major, v.Minor - o.Minor, v.Patch - o.Patch} { if d != 0 { return sign(d) } } switch { case v.Pre == o.Pre: return 0 case v.Pre == "": return 1 case o.Pre == "": return -1 case v.Pre < o.Pre: return -1 } return 1 } func (v Version) Less(o Version) bool { return v.Compare(o) < 0 } // NextBreaking is the first version that may break compatibility with v. // // Below 1.0.0 the minor is the breaking axis (SemVer §4: anything may change in 0.y), so 0.4.2's // next break is 0.5.0, not 1.0.0. Getting this wrong in the permissive direction would let a // 0.5 server accept a 0.4 app that cannot speak to it. func (v Version) NextBreaking() Version { if v.Major == 0 { return Version{Major: 0, Minor: v.Minor + 1} } return Version{Major: v.Major + 1} } // Range is [Min, Max): minimum inclusive, maximum exclusive. An unset Max means unbounded. // // Exclusive on the upper end because the useful bound is always "the version that broke it", // and writing that literally ("< 1.0.0") is unambiguous in a way that "<= 0.999.999" is not. type Range struct { Min Version Max Version HasMax bool } func (r Range) Contains(v Version) bool { if v.Less(r.Min) { return false } if r.HasMax && !v.Less(r.Max) { return false } return true } func (r Range) String() string { if !r.HasMax { return ">= " + r.Min.String() } return ">= " + r.Min.String() + ", < " + r.Max.String() } // ParseRange builds a range from two strings; an empty max means unbounded. A malformed bound is // an error rather than a silently ignored one: a typo in an operator's config must not quietly // turn a restriction off. func ParseRange(min, max string) (Range, error) { lo, ok := Parse(min) if !ok { return Range{}, fmt.Errorf("bad minimum version %q", min) } if strings.TrimSpace(max) == "" { return Range{Min: lo}, nil } hi, ok := Parse(max) if !ok { return Range{}, fmt.Errorf("bad maximum version %q", max) } if hi.Less(lo) || hi.Compare(lo) == 0 { return Range{}, fmt.Errorf("maximum %s is not above minimum %s", max, min) } return Range{Min: lo, Max: hi, HasMax: true}, nil } // Verdict is the outcome of a compatibility check. type Verdict int const ( OK Verdict = iota TooOld TooNew // Unknown means the peer did not say, or said something unparseable — a development build, // or a client too old to send its version at all. Unknown ) // Check reports whether peer falls in r, and explains the answer in words meant for a person. // The message is deliberately actionable: it names both versions and what to do about it, since // it is the only thing the user on the other end will see. func Check(peer string, r Range, peerName string) (Verdict, string) { v, ok := Parse(peer) if !ok { return Unknown, fmt.Sprintf("%s did not report a usable version (%q); proceeding without a compatibility check", peerName, peer) } switch { case v.Less(r.Min): return TooOld, fmt.Sprintf("%s %s is older than this build supports (needs %s). Update the %s.", peerName, v, r, peerName) case r.HasMax && !v.Less(r.Max): return TooNew, fmt.Sprintf("%s %s is newer than this build supports (accepts %s). Update this side, or point at a %s within range.", peerName, v, r, peerName) } return OK, "" } func sign(d int) int { if d < 0 { return -1 } if d > 0 { return 1 } return 0 }