Skip to content

Commit d18a565

Browse files
authored
Merge pull request #983 from meshery/fix/syncmodutil-pin-shared-deps-for-plugin-abi
fix(syncmodutil): pin shared deps via replace to keep Go plugin ABI stable
2 parents 538573f + 56367b5 commit d18a565

2 files changed

Lines changed: 187 additions & 6 deletions

File tree

cmd/syncmodutil/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,32 @@
22

33
go run github.com/meshery/meshkit/cmd/syncmodutil < path to src go mod > < path to destination go mod >
44

5+
Pass `--err` as a third argument to fail (non-zero exit) when the destination
6+
`require` block already contains a different version than the source for any
7+
shared module, instead of rewriting it in place.
8+
9+
## What it does
10+
11+
1. Rewrites every shared `require` line in the destination `go.mod` to match
12+
the source's version.
13+
2. Appends a `replace` block that pins every module in the source module
14+
graph to the source's exact selected version. Source-declared `replace`
15+
directives are preserved verbatim and take precedence over pinning.
16+
17+
Step 2 is what makes the result safe for **Go plugin builds**. Without it, a
18+
`go mod tidy` run after sync can upgrade transitive dependencies past what
19+
the host binary is linked against — causing `plugin.Open` to fail at runtime
20+
with `plugin was built with a different version of package ...`. Because
21+
`replace` directives override `require` resolution, the destination module
22+
is guaranteed to compile against the same versions as the source no matter
23+
what `tidy` does afterwards.
524

625
# Caveats
726
1. Always perform a build test of destination go module after syncing to make sure that nothing breaks.
827
2. If destination go module relied on a specific version having similar API contract but different internal logic, then you may be in trouble.
28+
3. The emitted `replace` block is intentionally broad (it pins every module
29+
in the source graph). Unused pins are harmless — `go mod tidy` keeps them
30+
but they have no effect on modules the destination does not import.
931

1032

1133
## Flow

cmd/syncmodutil/internal/modsync/sync.go

Lines changed: 165 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,22 +71,150 @@ func (g *GoMod) SyncRequire(f io.Reader, throwerr bool) (gomod string, err error
7171
}
7272
}
7373
}
74-
if len(g.ReplacedVersions) != 0 {
75-
data = append(data, "replace(")
74+
75+
// Emit a replace block that pins every package in the source (host)
76+
// module graph to its exact source-selected version. Pinning via
77+
// `replace` — rather than relying on `require` alone — is required for
78+
// Go plugin ABI compatibility. Without it, the `go mod tidy` step that
79+
// callers run after this tool can upgrade transitive dependencies past
80+
// what the host binary is linked against (for example, a direct
81+
// dependency on github.com/99designs/gqlgen pulling in a newer
82+
// github.com/vektah/gqlparser/v2 than the host uses), causing
83+
// plugin.Open to fail at runtime with:
84+
// "plugin was built with a different version of package <pkg>".
85+
// Because replace directives override require resolution, this
86+
// guarantees the destination module is compiled against the exact same
87+
// versions as the source. Source-declared replaces take precedence over
88+
// pinning and are emitted verbatim.
89+
replacedMods := make(map[string]bool, len(g.ReplacedVersions))
90+
for _, r := range g.ReplacedVersions {
91+
if len(r) >= 1 {
92+
replacedMods[r[0].Name] = true
93+
}
94+
}
95+
96+
// Compute the set of modules we will emit a replace directive for, so
97+
// that any pre-existing replace in the destination for the same module
98+
// can be stripped first. Leaving both in place would cause `go mod tidy`
99+
// to fail with "multiple replacements for <module>".
100+
emitModules := make(map[string]bool, len(g.RequiredVersions)+len(g.ReplacedVersions))
101+
for _, required := range g.RequiredVersions {
102+
if !replacedMods[required.Name] {
103+
emitModules[required.Name] = true
104+
}
105+
}
106+
for _, r := range g.ReplacedVersions {
107+
if len(r) >= 1 {
108+
emitModules[r[0].Name] = true
109+
}
110+
}
111+
data = stripConflictingReplaces(data, emitModules)
112+
113+
if len(g.RequiredVersions) > 0 || len(g.ReplacedVersions) > 0 {
114+
data = append(data, "replace (")
115+
}
116+
117+
pinned := make(map[string]bool, len(g.RequiredVersions))
118+
for _, required := range g.RequiredVersions {
119+
if pinned[required.Name] || replacedMods[required.Name] {
120+
continue
121+
}
122+
pinned[required.Name] = true
123+
data = append(data, fmt.Sprintf("\t%s => %s %s", required.Name, required.Name, required.Version))
76124
}
77125

78-
//Add all the replaced versions from source to destination. Running go mod tidy after the utility will perform the cleanup in the destination go.mod and remove all the unwanted replace statements.
79-
//Instead of trying to intelligently perform diffs, it is better to let the go mod tidy do the cleanup.
126+
// Add all the replaced versions from source to destination. Running go
127+
// mod tidy after the utility will perform the cleanup in the destination
128+
// go.mod and remove unused entries. Instead of trying to intelligently
129+
// perform diffs, it is better to let `go mod tidy` do the cleanup.
80130
for _, replaced := range g.ReplacedVersions {
81-
data = append(data, fmt.Sprintf("\t%s %s => %s %s", replaced[0].Name, replaced[0].Version, replaced[1].Name, replaced[1].Version))
131+
data = append(data, formatReplaceLine(replaced))
82132
}
83-
if len(g.ReplacedVersions) != 0 {
133+
134+
if len(g.RequiredVersions) > 0 || len(g.ReplacedVersions) > 0 {
84135
data = append(data, ")")
85136
}
86137
gomod = strings.Join(data, "\n")
87138
return
88139
}
89140

141+
// stripConflictingReplaces removes any replace directive from the
142+
// destination go.mod lines whose "from" module is in the conflicts set.
143+
// Both forms are handled: single-line (`replace foo => bar v1`) and lines
144+
// inside an existing `replace (...)` block. Replaces for modules not in
145+
// conflicts are preserved — including destination-specific overrides that
146+
// the source does not touch. Empty replace blocks that may be left behind
147+
// are valid go.mod syntax; `go mod tidy` cleans them up.
148+
func stripConflictingReplaces(data []string, conflicts map[string]bool) []string {
149+
if len(conflicts) == 0 {
150+
return data
151+
}
152+
out := make([]string, 0, len(data))
153+
inReplaceBlock := false
154+
for _, line := range data {
155+
trim := strings.TrimSpace(line)
156+
157+
if !inReplaceBlock && (trim == "replace (" || trim == "replace(") {
158+
inReplaceBlock = true
159+
out = append(out, line)
160+
continue
161+
}
162+
if inReplaceBlock && trim == ")" {
163+
inReplaceBlock = false
164+
out = append(out, line)
165+
continue
166+
}
167+
168+
if strings.Contains(trim, "=>") {
169+
var rest string
170+
switch {
171+
case inReplaceBlock:
172+
rest = trim
173+
default:
174+
// go.mod allows arbitrary whitespace after the `replace`
175+
// keyword (e.g. `replace\tfoo => bar`), so tokenize rather
176+
// than match a fixed "replace " prefix. Skip any other line
177+
// containing "=>" (e.g. an in-comment arrow).
178+
fields := strings.Fields(trim)
179+
if len(fields) == 0 || fields[0] != "replace" {
180+
out = append(out, line)
181+
continue
182+
}
183+
rest = strings.TrimSpace(strings.TrimPrefix(trim, fields[0]))
184+
}
185+
parts := strings.SplitN(rest, "=>", 2)
186+
from := strings.Fields(strings.TrimSpace(parts[0]))
187+
if len(from) >= 1 && conflicts[from[0]] {
188+
continue
189+
}
190+
}
191+
192+
out = append(out, line)
193+
}
194+
return out
195+
}
196+
197+
// formatReplaceLine renders a single replace directive. The "from" version
198+
// is optional (e.g. `foo => ../foo`); the "to" version is absent for
199+
// local-path replacements.
200+
func formatReplaceLine(r []Package) string {
201+
if len(r) < 2 {
202+
return ""
203+
}
204+
from, to := r[0], r[1]
205+
206+
left := from.Name
207+
if from.Version != "" {
208+
left = from.Name + " " + from.Version
209+
}
210+
211+
right := to.Name
212+
if to.Version != "" {
213+
right = to.Name + " " + to.Version
214+
}
215+
return "\t" + left + " => " + right
216+
}
217+
90218
// NewGoMod takes an io.Reader to a go.mod and returns GoMod struct
91219
func New(f io.Reader) (*GoMod, error) {
92220
b, err := io.ReadAll(f)
@@ -130,6 +258,7 @@ func getRequiredVersionsFromString(s string) (p []Package) {
130258
return p
131259
}
132260
func getReplacedVersionsFromString(s string) (p [][]Package) {
261+
// Block form: replace ( ... )
133262
reps := ReplacePatternRegex.FindAllString(s, -1)
134263
for _, req := range reps {
135264
data := getStringWithinCharacters(req, '(', ')')
@@ -146,6 +275,36 @@ func getReplacedVersionsFromString(s string) (p [][]Package) {
146275
}
147276
}
148277
}
278+
279+
// Single-line form: `replace foo [v] => bar [v]` outside any block.
280+
// go.mod allows arbitrary whitespace after the `replace` keyword, so
281+
// tokenize rather than match a fixed prefix.
282+
inBlock := false
283+
for _, line := range strings.Split(s, "\n") {
284+
trim := strings.TrimSpace(line)
285+
if !inBlock && (trim == "replace (" || trim == "replace(") {
286+
inBlock = true
287+
continue
288+
}
289+
if inBlock {
290+
if trim == ")" {
291+
inBlock = false
292+
}
293+
continue
294+
}
295+
if !strings.Contains(trim, "=>") {
296+
continue
297+
}
298+
fields := strings.Fields(trim)
299+
if len(fields) == 0 || fields[0] != "replace" {
300+
continue
301+
}
302+
rest := strings.TrimSpace(strings.TrimPrefix(trim, fields[0]))
303+
p0 := getPackagesAndVersionsFromPackageVersions(rest)
304+
if len(p0) != 0 {
305+
p = append(p, p0)
306+
}
307+
}
149308
return p
150309
}
151310
func getPackagesAndVersionsFromPackageVersions(pkg string) (p []Package) {

0 commit comments

Comments
 (0)