From 501781efc9d30da4bf2da46fb46989730e218237 Mon Sep 17 00:00:00 2001 From: viju Date: Sun, 8 Nov 2026 01:28:56 +0530 Subject: [PATCH 1/3] ci: implementing grep logic ref #852 Signed-off-by: viju --- .clusterfuzzlite/build.sh | 26 +++++- .github/linters/urunc-dict.txt | 1 + internal/fuzzseed/fuzzseed.go | 92 ++++++++++++++++++++++ internal/fuzzseed/seeds.toml | 1 + pkg/unikontainers/annotations_fuzz_test.go | 4 +- 5 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 internal/fuzzseed/fuzzseed.go create mode 100644 internal/fuzzseed/seeds.toml diff --git a/.clusterfuzzlite/build.sh b/.clusterfuzzlite/build.sh index f0222d4de..73d7c9638 100755 --- a/.clusterfuzzlite/build.sh +++ b/.clusterfuzzlite/build.sh @@ -20,8 +20,26 @@ # go-118-fuzz-build cannot patch a toolchain that go downloads into the module cache. export GOTOOLCHAIN=local -fuzzer=FuzzGetUnikernelConfig -compile_native_go_fuzzer_v2 github.com/urunc-dev/urunc/pkg/unikontainers "$fuzzer" "$fuzzer" +# Every Fuzz function of a test file, as :. +targets=$(grep -rHoE --include='*_test.go' '^func Fuzz[A-Za-z0-9_]+' pkg internal | sed 's|/[^/]*:func |:|' | sort -u) +test -n "$targets" +# The built fuzzers are named after their function. +test -z "$(cut -d: -f2 <<<"$targets" | sort | uniq -d)" -# compile_native_go_fuzzer_v2 exits 0 when it cannot find the function. -test -x "$OUT/$fuzzer" +module=$(go list -m) +zips=$(mktemp -d -p "$OUT") +for target in $targets; do + dir=${target%%:*} fuzzer=${target#*:} + compile_native_go_fuzzer_v2 "$module/$dir" "$fuzzer" "$fuzzer" + + # compile_native_go_fuzzer_v2 exits 0 when it cannot find the function. + test -x "$OUT/$fuzzer" + + # libFuzzer ignores f.Add, so the seeds are dumped to files first. + seeds=$(mktemp -d) + URUNC_SEED_DIR=$seeds go test -run "^$fuzzer\$" "./$dir" + if [ -n "$(ls -A "$seeds")" ]; then + TMPDIR=$zips addStdLibCorpusToFuzzer -dir "$seeds" -fuzzer_name "$fuzzer" + fi +done +rm -rf "$zips" diff --git a/.github/linters/urunc-dict.txt b/.github/linters/urunc-dict.txt index 869a4d739..7c2a38ca8 100644 --- a/.github/linters/urunc-dict.txt +++ b/.github/linters/urunc-dict.txt @@ -504,3 +504,4 @@ errgrpc Inuth clusterfuzzlite gotoolchain +fuzzseed diff --git a/internal/fuzzseed/fuzzseed.go b/internal/fuzzseed/fuzzseed.go new file mode 100644 index 000000000..8d8256a61 --- /dev/null +++ b/internal/fuzzseed/fuzzseed.go @@ -0,0 +1,92 @@ +// Copyright (c) 2023-2026, Nubificus LTD +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package fuzzseed holds the seeds of the fuzz targets in seeds.toml. It adds them to a +// target and, for the ClusterFuzzLite build, which ignores f.Add, writes them to files. +package fuzzseed + +import ( + "crypto/sha256" + _ "embed" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/BurntSushi/toml" +) + +//go:embed seeds.toml +var seedFile string + +// seedTables is seeds.toml, parsed once: a libFuzzer build calls the fuzz function for every input. +var seedTables = sync.OnceValues(func() (map[string]toml.Primitive, toml.MetaData) { + var tables map[string]toml.Primitive + md, err := toml.Decode(seedFile, &tables) + if err != nil { + panic(err) + } + + return tables, md +}) + +var ( + loadedMu sync.Mutex + loaded = map[string]any{} +) + +// Load returns the seed rows of a target, decoded into T. It panics if there are none. +func Load[T any](target string) []T { + loadedMu.Lock() + defer loadedMu.Unlock() + if rows, ok := loaded[target]; ok { + return rows.([]T) + } + tables, md := seedTables() + var rows []T + if err := md.PrimitiveDecode(tables[target], &rows); err != nil || len(rows) == 0 { + panic(fmt.Sprintf("no usable seeds for %s in seeds.toml: %v", target, err)) + } + loaded[target] = rows + + return rows +} + +// Add is f.Add. When URUNC_SEED_DIR is set it also writes the seed there in the +// "go test fuzz v1" format, named by its hash. +func Add(f *testing.F, args ...any) { + f.Add(args...) + dir := os.Getenv("URUNC_SEED_DIR") + if dir == "" { + return + } + var file strings.Builder + file.WriteString("go test fuzz v1\n") + for _, a := range args { + switch v := a.(type) { + case string: + fmt.Fprintf(&file, "string(%q)\n", v) + case []byte: + fmt.Fprintf(&file, "[]byte(%q)\n", v) + default: + fmt.Fprintf(&file, "%T(%v)\n", v, v) + } + } + name := fmt.Sprintf("%x", sha256.Sum256([]byte(file.String()))) + if err := os.WriteFile(filepath.Join(dir, name), []byte(file.String()), 0o600); err != nil { //nolint:gosec // name is a hash + f.Fatal(err) + } +} diff --git a/internal/fuzzseed/seeds.toml b/internal/fuzzseed/seeds.toml new file mode 100644 index 000000000..70d1fdf0a --- /dev/null +++ b/internal/fuzzseed/seeds.toml @@ -0,0 +1 @@ +# Seeds of the fuzz targets, by target name. Keep one only if it reaches code no other does. diff --git a/pkg/unikontainers/annotations_fuzz_test.go b/pkg/unikontainers/annotations_fuzz_test.go index 531c29acb..532c8a1d8 100644 --- a/pkg/unikontainers/annotations_fuzz_test.go +++ b/pkg/unikontainers/annotations_fuzz_test.go @@ -20,12 +20,14 @@ import ( "testing" "github.com/opencontainers/runtime-spec/specs-go" + + "github.com/urunc-dev/urunc/internal/fuzzseed" ) // FuzzGetUnikernelConfig feeds arbitrary bytes as the image's urunc.json. func FuzzGetUnikernelConfig(f *testing.F) { // A valid config: linux, qemu and /app, base64 encoded. - f.Add([]byte(`{"` + annotType + `":"bGludXg=","` + annotHypervisor + `":"cWVtdQ==","` + annotBinary + `":"L2FwcA=="}`)) + fuzzseed.Add(f, []byte(`{"`+annotType+`":"bGludXg=","`+annotHypervisor+`":"cWVtdQ==","`+annotBinary+`":"L2FwcA=="}`)) rootfs := f.TempDir() From 829d80973f8b8ae2dc34dd26dec2117a599d5666 Mon Sep 17 00:00:00 2001 From: viju Date: Sun, 8 Nov 2026 01:29:24 +0530 Subject: [PATCH 2/3] test: fuzz fetchUruncAnnotations ref #852 Signed-off-by: viju --- Makefile | 9 +- internal/fuzzseed/seeds.toml | 13 ++ .../containerd/annotations_fuzz_test.go | 142 ++++++++++++++++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 pkg/containerd-shim/containerd/annotations_fuzz_test.go diff --git a/Makefile b/Makefile index afe535844..97216e31d 100644 --- a/Makefile +++ b/Makefile @@ -284,7 +284,7 @@ test_unikernels: ## fuzz Run every Go fuzz target for FUZZTIME .PHONY: fuzz -fuzz: fuzz_unikontainers_GetUnikernelConfig +fuzz: fuzz_unikontainers_GetUnikernelConfig fuzz_shim_FetchUruncAnnotations ## fuzz_unikontainers_[name] Run the fuzz target Fuzz[name] of the unikontainers package .PHONY: fuzz_unikontainers_% @@ -293,6 +293,13 @@ fuzz_unikontainers_%: @GOFLAGS=$(TEST_FLAGS) $(GO) test $(TEST_OPTS) ./pkg/unikontainers -run='^$$' -fuzz='^Fuzz$*$$' -fuzztime=$(FUZZTIME) -v @echo " " +## fuzz_shim_[name] Run the fuzz target Fuzz[name] of the containerd shim package +.PHONY: fuzz_shim_% +fuzz_shim_%: + @echo "Fuzzing Fuzz$* in the containerd shim" + @GOFLAGS=$(TEST_FLAGS) $(GO) test $(TEST_OPTS) ./pkg/containerd-shim/containerd -run='^$$' -fuzz='^Fuzz$*$$' -fuzztime=$(FUZZTIME) -v + @echo " " + ## test_nerdctl Run all end-to-end tests with nerdctl .PHONY: test_nerdctl test_nerdctl: diff --git a/internal/fuzzseed/seeds.toml b/internal/fuzzseed/seeds.toml index 70d1fdf0a..721fbca3f 100644 --- a/internal/fuzzseed/seeds.toml +++ b/internal/fuzzseed/seeds.toml @@ -1 +1,14 @@ # Seeds of the fuzz targets, by target name. Keep one only if it reaches code no other does. + +FuzzFetchUruncAnnotations = [ + # the manifest overrides the label, other keys are dropped, sent in two chunks + { labelKey = "com.urunc.unikernel.a", labelValue = "label", mediaType = "application/vnd.oci.image.manifest.v1+json", cut = 7, manifest = '{"annotations":{"com.urunc.unikernel.a":"manifest","com.urunc.unikernel.b":"2","com.urunc.c":"3"}}' }, + { labelKey = "com.urunc.unikernel.a", labelValue = "label", mediaType = "application/vnd.docker.distribution.manifest.v2+json", manifest = '{"annotations":{"com.urunc.unikernel.b":"2"}}' }, + # an index or no image: the store is not read + { labelKey = "com.urunc.unikernel.a", labelValue = "label", mediaType = "application/vnd.oci.image.index.v1+json", manifest = '{"annotations":{"com.urunc.unikernel.b":"2"}}' }, + { labelKey = "com.urunc.a", labelValue = "label" }, + { labelKey = "com.urunc.unikernel.a", mediaType = "application/vnd.oci.image.manifest.v1+json", manifest = '{}' }, + { mediaType = "application/vnd.oci.image.manifest.v1+json", manifest = '{"annotations":' }, + { mediaType = "application/vnd.oci.image.manifest.v1+json", manifest = '{}', readFails = true }, + { mediaType = "application/vnd.oci.image.manifest.v1+json", manifest = '{}', cut = 1, streamBreaks = true }, +] diff --git a/pkg/containerd-shim/containerd/annotations_fuzz_test.go b/pkg/containerd-shim/containerd/annotations_fuzz_test.go new file mode 100644 index 000000000..bce2ffd6d --- /dev/null +++ b/pkg/containerd-shim/containerd/annotations_fuzz_test.go @@ -0,0 +1,142 @@ +// Copyright (c) 2023-2026, Nubificus LTD +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package containerd + +import ( + "context" + "encoding/json" + "errors" + "io" + "maps" + "strings" + "testing" + "unicode/utf8" + + contentapi "github.com/containerd/containerd/api/services/content/v1" + typesapi "github.com/containerd/containerd/api/types" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/pkg/namespaces" + imagespec "github.com/opencontainers/image-spec/specs-go/v1" + "google.golang.org/grpc" + + "github.com/urunc-dev/urunc/internal/fuzzseed" +) + +const ( + testNamespace = "k8s.io" + testDigest = "sha256:x" +) + +// fakeStore is a content store that holds one blob and sends it in chunks. +type fakeStore struct { + contentapi.ContentClient + grpc.ClientStream + chunks [][]byte + size int64 + readFails bool + streamBreaks bool // fail before the last chunk + reads int + wrongRead bool // a Read asked for another digest, size or namespace +} + +func (s *fakeStore) Read(ctx context.Context, req *contentapi.ReadContentRequest, _ ...grpc.CallOption) (contentapi.Content_ReadClient, error) { + s.reads++ + if ns, _ := namespaces.Namespace(ctx); ns != testNamespace || req.Digest != testDigest || req.Size != s.size { + s.wrongRead = true + } + if s.readFails { + return nil, errors.New("read failed") + } + return s, nil +} + +func (s *fakeStore) Recv() (*contentapi.ReadContentResponse, error) { + if len(s.chunks) == 0 { + return nil, io.EOF + } + if s.streamBreaks && len(s.chunks) == 1 { + return nil, errors.New("stream broke") + } + data := s.chunks[0] + s.chunks = s.chunks[1:] + return &contentapi.ReadContentResponse{Data: data}, nil +} + +type fetchSeed struct { + LabelKey, LabelValue, MediaType, Manifest string + Cut uint16 + ReadFails, StreamBreaks bool +} + +// FuzzFetchUruncAnnotations fetches the urunc annotations of a container with +// one label and an image manifest held in a fake content store. +func FuzzFetchUruncAnnotations(f *testing.F) { + for _, s := range fuzzseed.Load[fetchSeed]("FuzzFetchUruncAnnotations") { + fuzzseed.Add(f, s.LabelKey, s.LabelValue, s.MediaType, []byte(s.Manifest), s.Cut, s.ReadFails, s.StreamBreaks) + } + + f.Fuzz(func(t *testing.T, labelKey, labelValue, mediaType string, manifest []byte, cut uint16, readFails, streamBreaks bool) { + // Labels come from protobuf strings, which are valid UTF-8. + if !utf8.ValidString(labelKey) || !utf8.ValidString(labelValue) { + t.Skip() + } + n := int(cut) % (len(manifest) + 1) + store := &fakeStore{chunks: [][]byte{manifest[:n], manifest[n:]}, size: int64(len(manifest)), readFails: readFails, streamBreaks: streamBreaks} + fetcher := &annotationFetcher{ + containerLabels: map[string]string{labelKey: labelValue}, + contentClient: store, + namespace: testNamespace, + } + if mediaType != "" { + fetcher.target = &typesapi.Descriptor{MediaType: mediaType, Digest: testDigest, Size: int64(len(manifest))} + } + + got, err := fetcher.fetchUruncAnnotations(context.Background()) + + // Only a manifest is read from the store, and only reading it can fail. + readsManifest := images.IsManifestType(mediaType) + var parsed imagespec.Manifest + wantErr := readsManifest && (readFails || streamBreaks || json.Unmarshal(manifest, &parsed) != nil) + if (err != nil) != wantErr || (err != nil && got != nil) { + t.Fatalf("got %v, error %v, want an error: %v", got, err, wantErr) + } + wantReads := 0 + if readsManifest { + wantReads = 1 + } + if store.reads != wantReads || store.wrongRead { + t.Fatalf("store read %d times, want %d, wrong request: %v", store.reads, wantReads, store.wrongRead) + } + if err != nil { + return + } + + // The urunc label, then the urunc manifest annotations, which win. + want := map[string]string{} + if strings.HasPrefix(labelKey, uruncPrefix) { + want[labelKey] = labelValue + } + if readsManifest { + for k, v := range parsed.Annotations { + if strings.HasPrefix(k, uruncPrefix) { + want[k] = v + } + } + } + if !maps.Equal(got, want) { + t.Fatalf("got %v, want %v", got, want) + } + }) +} From fd5e0215b0393f42c76975eb9a34ea7fec35ccc7 Mon Sep 17 00:00:00 2001 From: viju Date: Sun, 8 Nov 2026 01:29:25 +0530 Subject: [PATCH 3/3] test: fuzz patchConfigJSON ref #852 Signed-off-by: viju --- Makefile | 2 +- internal/fuzzseed/seeds.toml | 7 ++ .../containerd/annotations_fuzz_test.go | 77 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 97216e31d..1394424a2 100644 --- a/Makefile +++ b/Makefile @@ -284,7 +284,7 @@ test_unikernels: ## fuzz Run every Go fuzz target for FUZZTIME .PHONY: fuzz -fuzz: fuzz_unikontainers_GetUnikernelConfig fuzz_shim_FetchUruncAnnotations +fuzz: fuzz_unikontainers_GetUnikernelConfig fuzz_shim_FetchUruncAnnotations fuzz_shim_PatchConfigJSON ## fuzz_unikontainers_[name] Run the fuzz target Fuzz[name] of the unikontainers package .PHONY: fuzz_unikontainers_% diff --git a/internal/fuzzseed/seeds.toml b/internal/fuzzseed/seeds.toml index 721fbca3f..1712e5921 100644 --- a/internal/fuzzseed/seeds.toml +++ b/internal/fuzzseed/seeds.toml @@ -12,3 +12,10 @@ FuzzFetchUruncAnnotations = [ { mediaType = "application/vnd.oci.image.manifest.v1+json", manifest = '{}', readFails = true }, { mediaType = "application/vnd.oci.image.manifest.v1+json", manifest = '{}', cut = 1, streamBreaks = true }, ] + +FuzzPatchConfigJSON = [ + # key1 is already there and keeps its value + { config = '{"hostname":"h","mounts":[{"destination":"/d","type":"bind","source":"/s"}],"annotations":{"com.urunc.unikernel.a":"old","keep":"1"}}', key1 = "com.urunc.unikernel.a", value1 = "new", key2 = "com.urunc.unikernel.b", value2 = "2" }, + { config = '{}', key1 = "com.urunc.unikernel.a", value1 = "1", key2 = "com.urunc.unikernel.b", value2 = "2" }, + { config = '{', key1 = "com.urunc.unikernel.a", value1 = "1" }, +] diff --git a/pkg/containerd-shim/containerd/annotations_fuzz_test.go b/pkg/containerd-shim/containerd/annotations_fuzz_test.go index bce2ffd6d..be43684c8 100644 --- a/pkg/containerd-shim/containerd/annotations_fuzz_test.go +++ b/pkg/containerd-shim/containerd/annotations_fuzz_test.go @@ -15,11 +15,14 @@ package containerd import ( + "bytes" "context" "encoding/json" "errors" "io" "maps" + "os" + "path/filepath" "strings" "testing" "unicode/utf8" @@ -29,6 +32,7 @@ import ( "github.com/containerd/containerd/v2/core/images" "github.com/containerd/containerd/v2/pkg/namespaces" imagespec "github.com/opencontainers/image-spec/specs-go/v1" + runtimespec "github.com/opencontainers/runtime-spec/specs-go" "google.golang.org/grpc" "github.com/urunc-dev/urunc/internal/fuzzseed" @@ -140,3 +144,76 @@ func FuzzFetchUruncAnnotations(f *testing.F) { } }) } + +type patchSeed struct { + Config string + Key1, Value1, Key2, Value2 string +} + +// FuzzPatchConfigJSON patches two annotations into a config.json. +func FuzzPatchConfigJSON(f *testing.F) { + for _, s := range fuzzseed.Load[patchSeed]("FuzzPatchConfigJSON") { + fuzzseed.Add(f, []byte(s.Config), s.Key1, s.Value1, s.Key2, s.Value2) + } + bundle := f.TempDir() + path := filepath.Join(bundle, "config.json") + + f.Fuzz(func(t *testing.T, config []byte, key1, value1, key2, value2 string) { + // Fetched annotations come from labels and JSON, so they are valid UTF-8. + for _, s := range []string{key1, value1, key2, value2} { + if !utf8.ValidString(s) { + t.Skip() + } + } + annotations := map[string]string{key1: value1, key2: value2} + if err := os.WriteFile(path, config, 0o640); err != nil { //nolint:gosec // a non-default mode, to check it is kept + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + var spec runtimespec.Spec + validSpec := json.Unmarshal(config, &spec) == nil + + patchErr := patchConfigJSON(bundle, annotations) + + patched, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if entries, _ := os.ReadDir(bundle); len(entries) != 1 { + t.Fatalf("%d files left in the bundle", len(entries)) + } + if !validSpec { + if patchErr == nil || !bytes.Equal(patched, config) { + t.Fatalf("invalid config.json: error %v, rewritten: %v", patchErr, !bytes.Equal(patched, config)) + } + return + } + if patchErr != nil { + t.Fatalf("patching a valid config.json: %v", patchErr) + } + if now, err := os.Stat(path); err != nil || now.Mode() != info.Mode() { + t.Fatalf("mode changed from %v: %v", info.Mode(), err) + } + + var out runtimespec.Spec + if err := json.Unmarshal(patched, &out); err != nil { + t.Fatalf("patched config.json is not a spec: %v", err) + } + // Missing annotations are added, existing ones keep their value. + want := maps.Clone(annotations) + maps.Copy(want, spec.Annotations) + if !maps.Equal(out.Annotations, want) { + t.Fatalf("annotations %v, want %v", out.Annotations, want) + } + // Nothing else changes. + spec.Annotations, out.Annotations = nil, nil + before, _ := json.Marshal(spec) + after, _ := json.Marshal(out) + if !bytes.Equal(before, after) { + t.Fatalf("config.json changed besides annotations:\n%s\n%s", before, after) + } + }) +}