Skip to content

Commit 08f0737

Browse files
authored
fix: golang-loader's image extraction (#471)
Fix golang-loader's image extraction logic. --- We found this bug until today, in some ways, these means no one use composer-lite for now. 😞 --------- Signed-off-by: wbpcode <wbphub@gmail.com>
1 parent 2e4c110 commit 08f0737

2 files changed

Lines changed: 156 additions & 13 deletions

File tree

extensions/composer/goplugin-loader/imagefetcher/fetcher.go

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ type Option struct {
3737
// maxPluginSize is the upper bound for a plugin's size (512 MB).
3838
const maxPluginSize int64 = 512 * 1024 * 1024
3939

40+
// errPluginBinaryNotFound is returned when a tar stream contains no .so file.
41+
// It is a sentinel so callers can distinguish "this layer has no plugin binary"
42+
// (keep looking in other layers) from a genuine extraction failure.
43+
var errPluginBinaryNotFound = errors.New(".so file not found in the archive")
44+
4045
// FetchPlugin fetches a Go plugin binary from an OCI registry and returns the
4146
// local file path. The ref should be a plain OCI reference (e.g.
4247
// "registry.example.com/plugins/myplugin:v1") without an "oci://" scheme prefix.
@@ -127,8 +132,12 @@ func fetchPluginBinary(img v1.Image, destPath string) error {
127132
return extractImageLayer(img, destPath, types.OCILayer)
128133
}
129134

130-
// extractImageLayer extracts the plugin binary from the last layer of an image,
131-
// validating that the layer has the expected media type.
135+
// extractImageLayer extracts the plugin binary from an image. The .so is not
136+
// necessarily in the last layer: images built with multiple COPY instructions
137+
// produce one layer per instruction, and the plugin binary often lives in an
138+
// earlier layer than the packaged manifests. Layers are searched newest-first
139+
// so a later layer can override an earlier one, mirroring how an image's
140+
// filesystem is assembled.
132141
func extractImageLayer(img v1.Image, destPath string, expectedMediaType types.MediaType) error {
133142
layers, err := img.Layers()
134143
if err != nil {
@@ -138,15 +147,30 @@ func extractImageLayer(img v1.Image, destPath string, expectedMediaType types.Me
138147
return errors.New("number of layers must be greater than zero")
139148
}
140149

141-
layer := layers[len(layers)-1]
142-
mt, err := layer.MediaType()
143-
if err != nil {
144-
return fmt.Errorf("failed to get media type: %w", err)
145-
}
146-
if mt != expectedMediaType {
147-
return fmt.Errorf("invalid media type %s (expect %s)", mt, expectedMediaType)
150+
for i := len(layers) - 1; i >= 0; i-- {
151+
layer := layers[i]
152+
mt, err := layer.MediaType()
153+
if err != nil {
154+
return fmt.Errorf("failed to get media type: %w", err)
155+
}
156+
if mt != expectedMediaType {
157+
continue
158+
}
159+
160+
if err := extractLayerBinary(layer, destPath); err != nil {
161+
if errors.Is(err, errPluginBinaryNotFound) {
162+
continue
163+
}
164+
return err
165+
}
166+
return nil
148167
}
168+
return errPluginBinaryNotFound
169+
}
149170

171+
// extractLayerBinary opens a single layer's compressed tar.gz stream and
172+
// extracts the plugin binary from it.
173+
func extractLayerBinary(layer v1.Layer, destPath string) error {
150174
r, err := layer.Compressed()
151175
if err != nil {
152176
return fmt.Errorf("failed to get layer content: %w", err)
@@ -217,5 +241,5 @@ func extractPluginBinary(r io.Reader, destPath string) error {
217241
}
218242
return nil
219243
}
220-
return fmt.Errorf(".so file not found in the archive")
244+
return errPluginBinaryNotFound
221245
}

extensions/composer/goplugin-loader/imagefetcher/fetcher_test.go

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,126 @@ func TestExtractImageLayer(t *testing.T) {
9999
}
100100
})
101101

102-
t.Run("invalid media type", func(t *testing.T) {
102+
t.Run("plugin binary in earlier layer", func(t *testing.T) {
103+
// Replicates an image built with multiple COPY instructions
104+
// (Dockerfile.plugin): the plugin .so lands in an early layer,
105+
// followed by manifest/schema layers that contain no .so. The
106+
// last layer is therefore NOT the plugin layer.
107+
exp := "this is plugin binary"
108+
pluginLayer, err := newMockLayer(tt.mediaType, map[string][]byte{
109+
"plugin.so": []byte(exp),
110+
})
111+
if err != nil {
112+
t.Fatal(err)
113+
}
114+
manifestLayer, err := newMockLayer(tt.mediaType, map[string][]byte{
115+
"manifest.yaml": []byte("name: example-go"),
116+
})
117+
if err != nil {
118+
t.Fatal(err)
119+
}
120+
schemaLayer, err := newMockLayer(tt.mediaType, map[string][]byte{
121+
"config.schema.json": []byte("{}"),
122+
})
123+
if err != nil {
124+
t.Fatal(err)
125+
}
126+
127+
// Append in build order: plugin first, then the manifests.
128+
img := empty.Image
129+
for _, l := range []v1.Layer{pluginLayer, manifestLayer, schemaLayer} {
130+
img, err = mutate.Append(img, mutate.Addendum{Layer: l})
131+
if err != nil {
132+
t.Fatal(err)
133+
}
134+
}
135+
136+
dest := filepath.Join(t.TempDir(), "plugin.so")
137+
if extractImageLayerErr := extractImageLayer(img, dest, tt.mediaType); extractImageLayerErr != nil {
138+
t.Fatalf("extractImageLayer failed: %v", extractImageLayerErr)
139+
}
140+
b, err := os.ReadFile(dest) //nolint:gosec // Test code reads from temp dir.
141+
if err != nil {
142+
t.Fatalf("failed to read written plugin: %v", err)
143+
}
144+
if string(b) != exp {
145+
t.Fatalf("got %s, but want %s", string(b), exp)
146+
}
147+
})
148+
149+
t.Run("no plugin binary in any layer", func(t *testing.T) {
150+
manifestLayer, err := newMockLayer(tt.mediaType, map[string][]byte{
151+
"manifest.yaml": []byte("name: example-go"),
152+
})
153+
if err != nil {
154+
t.Fatal(err)
155+
}
156+
schemaLayer, err := newMockLayer(tt.mediaType, map[string][]byte{
157+
"config.schema.json": []byte("{}"),
158+
})
159+
if err != nil {
160+
t.Fatal(err)
161+
}
162+
163+
img := empty.Image
164+
for _, l := range []v1.Layer{manifestLayer, schemaLayer} {
165+
img, err = mutate.Append(img, mutate.Addendum{Layer: l})
166+
if err != nil {
167+
t.Fatal(err)
168+
}
169+
}
170+
171+
dest := filepath.Join(t.TempDir(), "plugin.so")
172+
err = extractImageLayer(img, dest, tt.mediaType)
173+
if err == nil || !strings.Contains(err.Error(), "not found") {
174+
t.Fatalf("extractImageLayer should fail with not found, got: %v", err)
175+
}
176+
})
177+
178+
t.Run("skips layer with unexpected media type", func(t *testing.T) {
179+
// A layer whose media type does not match (e.g. a config layer)
180+
// must be skipped, not treated as fatal, so the plugin in a
181+
// matching layer is still found. Build order: plugin first, then
182+
// a mismatched layer last (searched first).
183+
exp := "this is plugin binary"
184+
pluginLayer, err := newMockLayer(tt.mediaType, map[string][]byte{
185+
"plugin.so": []byte(exp),
186+
})
187+
if err != nil {
188+
t.Fatal(err)
189+
}
190+
mismatchedLayer, err := newMockLayer(types.DockerPluginConfig, map[string][]byte{
191+
"config.json": []byte("{}"),
192+
})
193+
if err != nil {
194+
t.Fatal(err)
195+
}
196+
197+
img := empty.Image
198+
for _, l := range []v1.Layer{pluginLayer, mismatchedLayer} {
199+
img, err = mutate.Append(img, mutate.Addendum{Layer: l})
200+
if err != nil {
201+
t.Fatal(err)
202+
}
203+
}
204+
205+
dest := filepath.Join(t.TempDir(), "plugin.so")
206+
if extractImageLayerErr := extractImageLayer(img, dest, tt.mediaType); extractImageLayerErr != nil {
207+
t.Fatalf("extractImageLayer failed: %v", extractImageLayerErr)
208+
}
209+
b, err := os.ReadFile(dest) //nolint:gosec // Test code reads from temp dir.
210+
if err != nil {
211+
t.Fatalf("failed to read written plugin: %v", err)
212+
}
213+
if string(b) != exp {
214+
t.Fatalf("got %s, but want %s", string(b), exp)
215+
}
216+
})
217+
218+
t.Run("only unexpected media type layers", func(t *testing.T) {
219+
// When no layer has the expected media type, the search finds no
220+
// plugin binary and reports not found (rather than aborting on
221+
// the first mismatch).
103222
l, err := newMockLayer(types.DockerPluginConfig, nil)
104223
if err != nil {
105224
t.Fatal(err)
@@ -110,8 +229,8 @@ func TestExtractImageLayer(t *testing.T) {
110229
}
111230
dest := filepath.Join(t.TempDir(), "plugin.so")
112231
err = extractImageLayer(img, dest, tt.mediaType)
113-
if err == nil || !strings.Contains(err.Error(), "invalid media type") {
114-
t.Fatal("extractImageLayer should fail due to invalid media type")
232+
if err == nil || !strings.Contains(err.Error(), "not found") {
233+
t.Fatalf("extractImageLayer should fail with not found, got: %v", err)
115234
}
116235
})
117236
})

0 commit comments

Comments
 (0)