Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions .clusterfuzzlite/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <package dir>:<name>.
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"
1 change: 1 addition & 0 deletions .github/linters/urunc-dict.txt
Original file line number Diff line number Diff line change
Expand Up @@ -504,3 +504,4 @@ errgrpc
Inuth
clusterfuzzlite
gotoolchain
fuzzseed
9 changes: 8 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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_shim_PatchConfigJSON

## fuzz_unikontainers_[name] Run the fuzz target Fuzz[name] of the unikontainers package
.PHONY: fuzz_unikontainers_%
Expand All @@ -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:
Expand Down
92 changes: 92 additions & 0 deletions internal/fuzzseed/fuzzseed.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
21 changes: 21 additions & 0 deletions internal/fuzzseed/seeds.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# 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 },
]

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" },
]
219 changes: 219 additions & 0 deletions pkg/containerd-shim/containerd/annotations_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// 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 (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"maps"
"os"
"path/filepath"
"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"
runtimespec "github.com/opencontainers/runtime-spec/specs-go"
"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)
}
})
}

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)
}
})
}
Loading