From 22734111c08e87be12917a20093480c4b5ac9088 Mon Sep 17 00:00:00 2001 From: Hank Donnay Date: Fri, 9 Jan 2026 15:07:40 -0600 Subject: [PATCH 1/2] wip: matcher wasm module Signed-off-by: Hank Donnay --- go.mod | 1 + go.sum | 2 + internal/matcher/wasm/host.go | 212 +++++++++++++++++ internal/matcher/wasm/host_test.go | 111 +++++++++ internal/matcher/wasm/matcher.go | 241 ++++++++++++++++++++ internal/matcher/wasm/matcher_test.go | 48 ++++ internal/matcher/wasm/testdata/Makefile | 9 + internal/matcher/wasm/testdata/trivial.c | 170 ++++++++++++++ internal/matcher/wasm/testdata/trivial.wasm | Bin 0 -> 732 bytes 9 files changed, 794 insertions(+) create mode 100644 internal/matcher/wasm/host.go create mode 100644 internal/matcher/wasm/host_test.go create mode 100644 internal/matcher/wasm/matcher.go create mode 100644 internal/matcher/wasm/matcher_test.go create mode 100644 internal/matcher/wasm/testdata/Makefile create mode 100644 internal/matcher/wasm/testdata/trivial.c create mode 100755 internal/matcher/wasm/testdata/trivial.wasm diff --git a/go.mod b/go.mod index 48ac68090..c619d75bd 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/quay/goval-parser v0.8.8 github.com/remind101/migrate v0.0.0-20170729031349-52c1edff7319 github.com/spdx/tools-golang v0.5.6 + github.com/tetratelabs/wazero v1.11.0 github.com/ulikunitz/xz v0.5.15 go.opentelemetry.io/otel v1.39.0 go.opentelemetry.io/otel/trace v1.39.0 diff --git a/go.sum b/go.sum index 2178c2fbb..ed4d8d641 100644 --- a/go.sum +++ b/go.sum @@ -105,6 +105,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/internal/matcher/wasm/host.go b/internal/matcher/wasm/host.go new file mode 100644 index 000000000..0bc6cf508 --- /dev/null +++ b/internal/matcher/wasm/host.go @@ -0,0 +1,212 @@ +package wasm + +import ( + "context" + "fmt" + "reflect" + "slices" + "strings" + "sync" + "unsafe" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + + "github.com/quay/claircore" +) + +// PtrMember is a helper to take a pointer to a Go struct, then return a +// pointer that's contained as a field. +func ptrMember(off uintptr) api.GoModuleFunc { + return func(ctx context.Context, mod api.Module, stack []uint64) { + // Take in *A, which has a *B at offset "off". + ref := unsafe.Pointer(api.DecodeExternref(stack[0])) // Shouldn't be nil. + ptrField := unsafe.Add(ref, off) // This pointer can't be nil. + ptr := *(*unsafe.Pointer)(ptrField) // Can be nil. + stack[0] = api.EncodeExternref(uintptr(ptr)) + } +} + +// PtrToMember is a helper to take a pointer to a Go struct, then return a +// pointer to a contained field. +func ptrToMember(off uintptr) api.GoModuleFunc { + return func(ctx context.Context, mod api.Module, stack []uint64) { + // Take in *A, which has a B at offset "off". + ref := unsafe.Pointer(api.DecodeExternref(stack[0])) // Shouldn't be nil. + ptr := unsafe.Add(ref, off) // This pointer can't be nil. + stack[0] = api.EncodeExternref(uintptr(ptr)) + } +} + +// StringMember is a helper to take a pointer to a Go struct, then return a +// copy of a string member to a caller-allocated buffer. +func stringMember(off uintptr) api.GoModuleFunc { + return func(ctx context.Context, mod api.Module, stack []uint64) { + // Unsure of another way to get this length information. + h := (*reflect.StringHeader)(unsafe.Add(unsafe.Pointer(api.DecodeExternref(stack[0])), off)) + offset := api.DecodeU32(stack[1]) + lim := int(api.DecodeU32(stack[2])) + s := unsafe.String((*byte)(unsafe.Pointer(h.Data)), h.Len) + sz := min(lim, len(s)) + if sz == 0 { + stack[0] = api.EncodeI32(0) + return + } + s = s[:sz] + mem := mod.ExportedMemory("memory") + if mem.WriteString(offset, s) { + stack[0] = api.EncodeI32(int32(sz)) + } else { + stack[0] = api.EncodeI32(0) + } + } +} + +// StringerMember is a helper to take a pointer to a Go struct, then place the +// string representation of a member into a caller-allocated buffer. +func stringerMember(off uintptr) api.GoModuleFunc { + return func(ctx context.Context, mod api.Module, stack []uint64) { + iface := (any)(unsafe.Pointer(api.DecodeExternref(stack[0]) + off)).(fmt.Stringer) + offset := api.DecodeU32(stack[1]) + lim := int(api.DecodeU32(stack[2])) + s := iface.String() + sz := min(lim, len(s)) + if mod.ExportedMemory("memory").WriteString(offset, s[:sz]) { + stack[0] = api.EncodeI32(int32(sz)) + } else { + stack[0] = api.EncodeI32(0) + } + } +} + +// NotNil checks that the passed externref is not-nil. +// +// This is needed because externrefs are unobservable from within WASM; they +// can only be handed back to the host and not manipulated in any way. +func notNil(ctx context.Context, mod api.Module, stack []uint64) { + if api.DecodeExternref(stack[0]) != 0 { + stack[0] = api.EncodeI32(1) + } else { + stack[0] = api.EncodeI32(0) + } +} + +type methodSpec struct { + Name string + Func api.GoModuleFunc + Params []api.ValueType + ParamNames []string + Results []api.ValueType + ResultNames []string +} + +func (s *methodSpec) Build(b wazero.HostModuleBuilder) wazero.HostModuleBuilder { + return b.NewFunctionBuilder(). + WithName(s.Name). + WithParameterNames(s.ParamNames...). + WithResultNames(s.ResultNames...). + WithGoModuleFunction(s.Func, s.Params, s.Results). + Export(s.Name) +} + +func gettersFor[T any]() []methodSpec { + t := reflect.TypeFor[T]() + recv := strings.ToLower(t.Name()) + out := make([]methodSpec, 0, t.NumField()) + + switch t { + // These types are passed-in and always valid. + case reflect.TypeFor[claircore.IndexRecord](), + reflect.TypeFor[claircore.Vulnerability](): + default: + out = append(out, methodSpec{ + Name: fmt.Sprintf("%s_valid", recv), + Func: notNil, + Params: []api.ValueType{api.ValueTypeExternref}, + Results: []api.ValueType{api.ValueTypeI32}, + ParamNames: []string{recv + "Ref"}, + ResultNames: []string{"ok"}, + }) + } + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + if !sf.IsExported() { + continue + } + if sf.Name == "ID" { // Skip "id" fields. + continue + } + + ft := sf.Type + tgt := strings.ToLower(sf.Name) + // Do some fixups: + switch tgt { + case "dist": + tgt = "distribution" + case "arch": + tgt = "architecture" + case "repo": + tgt = "repository" + } + mi := len(out) + out = append(out, methodSpec{}) + m := &out[mi] + m.Name = fmt.Sprintf("%s_get_%s", recv, tgt) + switch ft.Kind() { + case reflect.Pointer: + m.Func = ptrMember(sf.Offset) + m.Params = []api.ValueType{api.ValueTypeExternref} + m.Results = []api.ValueType{api.ValueTypeExternref} + m.ParamNames = []string{recv + "Ref"} + m.ResultNames = []string{tgt + "Ref"} + case reflect.String: + m.Func = stringMember(sf.Offset) + m.Params = []api.ValueType{api.ValueTypeExternref, api.ValueTypeI32, api.ValueTypeI32} + m.Results = []api.ValueType{api.ValueTypeI32} + m.ParamNames = []string{recv + "Ref", "buf", "buf_len"} + m.ResultNames = []string{"len"} + case reflect.Struct: + switch { + case ft == reflect.TypeFor[claircore.Version](): + m.Func = ptrToMember(sf.Offset) + m.Params = []api.ValueType{api.ValueTypeExternref} + m.Results = []api.ValueType{api.ValueTypeExternref} + m.ParamNames = []string{recv + "Ref"} + m.ResultNames = []string{tgt + "Ref"} + case ft.Implements(reflect.TypeFor[fmt.Stringer]()): + m.Func = stringerMember(sf.Offset) + m.Params = []api.ValueType{api.ValueTypeExternref, api.ValueTypeI32, api.ValueTypeI32} + m.Results = []api.ValueType{api.ValueTypeI32} + m.ParamNames = []string{recv + "Ref", "buf", "buf_len"} + m.ResultNames = []string{"len"} + default: + out = out[:mi] + } + default: + out = out[:mi] + } + } + + return slices.Clip(out) +} + +var hostV1Interface = sync.OnceValue(func() []methodSpec { + return slices.Concat( + gettersFor[claircore.IndexRecord](), + gettersFor[claircore.Detector](), + gettersFor[claircore.Distribution](), + gettersFor[claircore.Package](), + gettersFor[claircore.Range](), + gettersFor[claircore.Repository](), + gettersFor[claircore.Version](), + gettersFor[claircore.Vulnerability](), + ) +}) + +func buildHostV1Interface(rt wazero.Runtime) wazero.HostModuleBuilder { + b := rt.NewHostModuleBuilder("claircore_matcher_1") + for _, spec := range hostV1Interface() { + b = spec.Build(b) + } + return b +} diff --git a/internal/matcher/wasm/host_test.go b/internal/matcher/wasm/host_test.go new file mode 100644 index 000000000..18d576a1a --- /dev/null +++ b/internal/matcher/wasm/host_test.go @@ -0,0 +1,111 @@ +package wasm + +import ( + "maps" + "os" + "slices" + "strings" + "sync" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + + "github.com/quay/claircore" + "github.com/quay/claircore/libvuln/driver" +) + +func init() { + // Override the disk caching for tests. + cache = sync.OnceValue(wazero.NewCompilationCache) +} + +func TestHostV1(t *testing.T) { + ctx := t.Context() + rConfig := runtimeConfig() + rt := wazero.NewRuntimeWithConfig(ctx, rConfig) + mod, err := buildHostV1Interface(rt).Compile(ctx) + if err != nil { + t.Fatal(err) + } + fns := mod.ExportedFunctions() + keys := slices.Collect(maps.Keys(fns)) + slices.Sort(keys) + var b strings.Builder + + writelist := func(ts []api.ValueType, ns []string) { + b.WriteByte('(') + for i := range ts { + if i != 0 { + b.WriteString(", ") + } + b.WriteString(ns[i]) + b.WriteString(": ") + switch ts[i] { + case api.ValueTypeExternref: + b.WriteString("externref") + case api.ValueTypeI32: + b.WriteString("i32") + case api.ValueTypeI64: + b.WriteString("i64") + case api.ValueTypeF32: + b.WriteString("f32") + case api.ValueTypeF64: + b.WriteString("f64") + default: + b.WriteString("???") + } + } + b.WriteByte(')') + } + for _, k := range keys { + v := fns[k] + b.Reset() + b.WriteString(v.DebugName()) + writelist(v.ParamTypes(), v.ParamNames()) + b.WriteString(" -> ") + writelist(v.ResultTypes(), v.ResultNames()) + + t.Log(b.String()) + } +} + +func TestTrivial(t *testing.T) { + ctx := t.Context() + f, err := os.Open("testdata/trivial.wasm") + if err != nil { + t.Fatal(err) + } + defer f.Close() + + m, err := NewMatcher(ctx, "trivial", f) + if err != nil { + t.Fatal(err) + } + + t.Run("Query", func(t *testing.T) { + want := []driver.MatchConstraint{driver.PackageName, driver.HasFixedInVersion} + got := m.Query() + if !cmp.Equal(got, want) { + t.Error(cmp.Diff(got, want)) + } + }) + + t.Log(`testing trvial matcher: "Filter() == true" when "len(IndexRecord.Package.Name) != 0"`) + r := &claircore.IndexRecord{ + Package: &claircore.Package{Name: "pkg"}, + } + ok := m.Filter(r) + t.Logf("package name %q: %v", r.Package.Name, ok) + if !ok { + t.Fail() + } + + r.Package = new(claircore.Package) + ok = m.Filter(r) + t.Logf("package name %q: %v", r.Package.Name, ok) + if ok { + t.Fail() + } +} diff --git a/internal/matcher/wasm/matcher.go b/internal/matcher/wasm/matcher.go new file mode 100644 index 000000000..bbab0d5e5 --- /dev/null +++ b/internal/matcher/wasm/matcher.go @@ -0,0 +1,241 @@ +package wasm + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "math" + "math/bits" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" + "unsafe" + + "github.com/quay/claircore" + "github.com/quay/claircore/libvuln/driver" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" +) + +const cachename = `matcher_wasm` + +func guessCachedir() (dirname string) { + // This uses the systemd cache convention: CACHE_DIRECTORY is a + // colon-separated list of directories for cache usage. + // + // See also: https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#RuntimeDirectory= + dirlist, ok := os.LookupEnv("CACHE_DIRECTORY") + if !ok { + // If unset, try the user cache dir and a static prefix. + if d, err := os.UserCacheDir(); err == nil { // NB on success + dirlist = filepath.Join(d, "claircore") + } + } + + // For each list element, pick the one that is either: + // - "Our" cache + // - The shortest (read: most general) path + // + // If there are no elements in the list, this will result in using the + // working directory. + sz := math.MaxInt + for d := range strings.SplitSeq(dirlist, ":") { + if filepath.Base(d) == cachename { + dirname = d + break + } + if len(d) < sz { + sz = len(d) + dirname = filepath.Join(d, cachename) + } + } + + return dirname +} + +var ( + cache = sync.OnceValue(func() wazero.CompilationCache { + cache, err := wazero.NewCompilationCacheWithDir(guessCachedir()) + if err != nil { + cache = wazero.NewCompilationCache() + } + return cache + }) + runtimeConfig = sync.OnceValue(func() wazero.RuntimeConfig { + const pages = 1024 // 1024 * 64KiB == 64MiB + return wazero.NewRuntimeConfig(). + WithCloseOnContextDone(true). + WithCompilationCache(cache()). + WithCustomSections(true). + WithMemoryLimitPages(pages). + WithCoreFeatures(api.CoreFeaturesV2) + }) +) + +var _ driver.Matcher = (*Matcher)(nil) + +func NewMatcher(ctx context.Context, name string, wasm io.Reader) (*Matcher, error) { + rt := wazero.NewRuntimeWithConfig(ctx, runtimeConfig()) + + var binary []byte + if b, ok := wasm.(*bytes.Buffer); ok { + binary = b.Bytes() + } else { + var err error + binary, err = io.ReadAll(wasm) + if err != nil { + return nil, err + } + } + compiled, err := rt.CompileModule(ctx, binary) + if err != nil { + return nil, err + } + + exp := compiled.ExportedFunctions() + for _, name := range []string{ + "filter", + "vulnerable", + } { + def, ok := exp[name] + if !ok { + err = errors.Join(err, fmt.Errorf("missing export %q", name)) + continue + } + + var in, out []api.ValueType + switch name { + case "filter": + in = []api.ValueType{api.ValueTypeExternref} + out = []api.ValueType{api.ValueTypeI32} + case "vulnerable": + in = []api.ValueType{api.ValueTypeExternref, api.ValueTypeExternref} + out = []api.ValueType{api.ValueTypeI32} + default: + panic("unreachable") + } + + if !slices.Equal(in, def.ParamTypes()) || !slices.Equal(out, def.ResultTypes()) { + err = errors.Join(err, fmt.Errorf("incorrect signature for %q", name)) + } + } + if err != nil { + return nil, fmt.Errorf("wasm: validation failed: %w", err) + } + + m := &Matcher{ + name: name, + rt: rt, + } + + if _, err := buildHostV1Interface(rt).Instantiate(ctx); err != nil { + return nil, err + } + + config := wazero.NewModuleConfig(). + WithStartFunctions() + m.mod, err = rt.InstantiateModule(ctx, compiled, config) + if err != nil { + return nil, err + } + // Allocate some memory by default. + mem := m.mod.Memory() + if ct, _ := mem.Grow(0); ct < 64 { + mem.Grow(64 - ct) + } + + qg := m.mod.ExportedGlobal("query") + if qg == nil || qg.Type() != api.ValueTypeI32 { + return nil, fmt.Errorf(`wasm: validation failed: missing exported offset "query"`) + } + var ok bool + m.queryConstraints, ok = mem.ReadUint32Le(api.DecodeU32(qg.Get())) + if !ok { + return nil, fmt.Errorf(`wasm: unable to read "query" value`) + } + + m.callFilter = m.mod.ExportedFunction("filter") + m.callVulnerable = m.mod.ExportedFunction("vulnerable") + + return m, nil +} + +type Matcher struct { + name string + rt wazero.Runtime + mod api.Module + + queryConstraints uint32 + callFilter api.Function + callVulnerable api.Function + + validateRecord *claircore.IndexRecord +} + +// Filter implements [driver.Matcher]. +func (m *Matcher) Filter(record *claircore.IndexRecord) bool { + var p runtime.Pinner + defer p.Unpin() + p.Pin(record) + p.Pin(record.Distribution) + p.Pin(record.Package) + p.Pin(record.Package.Detector) + p.Pin(record.Package.Source) + p.Pin(record.Repository) + + ret, err := m.callFilter.Call(context.Background(), /*???*/ + api.EncodeExternref(uintptr(unsafe.Pointer(record)))) + if err != nil { + panic(err) + } + ok := api.DecodeI32(ret[0]) != 0 + return ok +} + +// Name implements [driver.Matcher]. +func (m *Matcher) Name() string { return m.name } + +// Vulnerable implements [driver.Matcher]. +func (m *Matcher) Vulnerable(ctx context.Context, record *claircore.IndexRecord, vuln *claircore.Vulnerability) (bool, error) { + var p runtime.Pinner + defer p.Unpin() + p.Pin(record) + p.Pin(record.Distribution) + p.Pin(record.Package) + p.Pin(record.Package.Detector) + p.Pin(record.Package.Source) + p.Pin(record.Repository) + p.Pin(vuln) + p.Pin(vuln.Dist) + p.Pin(vuln.Package) + p.Pin(vuln.Package.Detector) + p.Pin(vuln.Package.Source) + p.Pin(vuln.Range) + p.Pin(vuln.Repo) + + ret, err := m.callVulnerable.Call(ctx, + api.EncodeExternref(uintptr(unsafe.Pointer(record))), + api.EncodeExternref(uintptr(unsafe.Pointer(vuln)))) + if err != nil { + return false, err + } + ok := api.DecodeI32(ret[0]) != 0 + return ok, nil +} + +// Query implements [driver.Matcher]. +func (m *Matcher) Query() []driver.MatchConstraint { + c := m.queryConstraints + mc := make([]driver.MatchConstraint, 0, bits.OnesCount32(c)) + for i := range 31 { + if c&(1< +#include +#include + +// Some magic builtins and compiler wrangling: +#define HOST(s) __attribute((import_module("claircore_matcher_1"), import_name(s))) +#define STRING_GETTER(t, r, f) HOST(#r "_get_" #f) ptrdiff_t r##_get_##f(t, char*, size_t) +#define REF_GETTER(t, e, r, f) HOST(#r "_get_" #f) e r##_get_##f(t) +#define REF_VALID(t, r) HOST(#r "_valid") bool r##_valid(t) +#define EXPORT(s) __attribute__((export_name(s))) +size_t __builtin_wasm_memory_size(int); +size_t __builtin_wasm_memory_grow(int, size_t); +extern char __heap_base[]; + +// Arena allocator. +// +// Thanks for the inspiration, skeeto: +// - https://nullprogram.com/blog/2023/09/27/ +// - https://nullprogram.com/blog/2025/04/19/ +// +// This is all extremely single-threaded. + +typedef struct { + char *beg; + char *end; +} Arena; + +//static bool init = false; + +Arena getarena(void) +{ +// if(!init){ +// // Allocate our heap. +// __builtin_wasm_memory_grow(0, __builtin_wasm_memory_size(0)); +// init = true; +// } + Arena a = {0}; + a.beg = __heap_base; + a.end = (char *)(__builtin_wasm_memory_size(0) << 16); + return a; +} + +static void *alloc(Arena *a, ptrdiff_t count, ptrdiff_t size, ptrdiff_t align) +{ + ptrdiff_t pad = (ptrdiff_t)-(size_t)a->beg & (align - 1); + char *r = a->beg + pad; + a->beg += pad + count*size; + return __builtin_memset(r, 0, (size_t)(size*count)); +} + +typedef struct { + char *s; + size_t len; + ptrdiff_t cap; +} Str; + +static Str allocStr(Arena *a, ptrdiff_t max) +{ + Str s = {0}; + char *p = alloc(a, 1, max, 4); + s.s = p; + s.cap = max; + return s; +} + +// Host interface: + +typedef __externref_t DetectorRef; +typedef __externref_t DistributionRef; +typedef __externref_t IndexrecordRef; +typedef __externref_t PackageRef; +typedef __externref_t RangeRef; +typedef __externref_t RepositoryRef; +typedef __externref_t VulnerabilityRef; + +typedef uint32_t MatchConstraints; +typedef enum MatchConstraintFlags{ + PackageSourceName = 1<<0, + PackageName = 1<<1, + PackageModule = 1<<2, + DistributionDID = 1<<3, + DistributionName = 1<<4, + DistributionVersion = 1<<5, + DistributionVersionCodeName = 1<<6, + DistributionVersionID = 1<<7, + DistributionArch = 1<<8, + DistributionCPE = 1<<9, + DistributionPrettyName = 1<<10, + RepositoryName = 1<<11, + RepositoryKey = 1<<12, + HasFixedInVersion = 1<<13, +} MatchConstraintFlags; + +REF_VALID(DetectorRef, detector); +STRING_GETTER(DetectorRef, detector, kind); +STRING_GETTER(DetectorRef, detector, name); +STRING_GETTER(DetectorRef, detector, version); + +REF_VALID(DistributionRef, distribution); +STRING_GETTER(DistributionRef, distribution, architecture); +STRING_GETTER(DistributionRef, distribution, cpe); +STRING_GETTER(DistributionRef, distribution, did); +STRING_GETTER(DistributionRef, distribution, name); +STRING_GETTER(DistributionRef, distribution, prettyname); +STRING_GETTER(DistributionRef, distribution, version); +STRING_GETTER(DistributionRef, distribution, versioncodename); +STRING_GETTER(DistributionRef, distribution, versionid); + +// Always valid. +REF_GETTER(IndexrecordRef, DistributionRef, indexrecord, distribution); +REF_GETTER(IndexrecordRef, PackageRef, indexrecord, package); +REF_GETTER(IndexrecordRef, RepositoryRef, indexrecord, repository); + +REF_VALID(PackageRef, package); +STRING_GETTER(PackageRef, package, architecture); +STRING_GETTER(PackageRef, package, cpe); +REF_GETTER(PackageRef, DetectorRef, package, detector); +STRING_GETTER(PackageRef, package, filepath); +STRING_GETTER(PackageRef, package, kind); +STRING_GETTER(PackageRef, package, module); +STRING_GETTER(PackageRef, package, name); +STRING_GETTER(PackageRef, package, packagedb); +STRING_GETTER(PackageRef, package, repositoryhint); +REF_GETTER(PackageRef, PackageRef, package, source); +STRING_GETTER(PackageRef, package, version); + +REF_VALID(RangeRef, range); + +REF_VALID(RepositoryRef, repository); +STRING_GETTER(RepositoryRef, repository, cpe); +STRING_GETTER(RepositoryRef, repository, key); +STRING_GETTER(RepositoryRef, repository, name); +STRING_GETTER(RepositoryRef, repository, uri); + +// Always valid. +STRING_GETTER(VulnerabilityRef, vulnerability, description); +REF_GETTER(VulnerabilityRef, DistributionRef, vulnerability, distribution); +STRING_GETTER(VulnerabilityRef, vulnerability, fixedinversion); +STRING_GETTER(VulnerabilityRef, vulnerability, issued); +STRING_GETTER(VulnerabilityRef, vulnerability, links); +STRING_GETTER(VulnerabilityRef, vulnerability, name); +REF_GETTER(VulnerabilityRef, PackageRef, vulnerability, package); +REF_GETTER(VulnerabilityRef, RangeRef, vulnerability, range); +REF_GETTER(VulnerabilityRef, RepositoryRef, vulnerability, repository); +STRING_GETTER(VulnerabilityRef, vulnerability, severity); +STRING_GETTER(VulnerabilityRef, vulnerability, updater); + +// Implementation: + +const MatchConstraints query = (PackageName|HasFixedInVersion); + +EXPORT("filter") +bool filter(IndexrecordRef r) { + Arena a = getarena(); + + PackageRef p = indexrecord_get_package(r); + if(!package_valid(p)) + return false; + + Str name = allocStr(&a, 1024); + ptrdiff_t len = package_get_name(p, name.s, name.cap); + name.len = len; + + return name.len > 0; +} + +EXPORT("vulnerable") +bool vulnerable(IndexrecordRef r, VulnerabilityRef v) { + return false; +} diff --git a/internal/matcher/wasm/testdata/trivial.wasm b/internal/matcher/wasm/testdata/trivial.wasm new file mode 100755 index 0000000000000000000000000000000000000000..b9388f0c0f5b7bd7096f98a1c37c93db09b7014d GIT binary patch literal 732 zcmZ`%O>fjN5S?+dAKmOKD=GmOq;yY3QC76-feTV|01iF$C#2qFHiUKT)Nz(BLQ43$ zbKvK4!08sLh~k51o@YEBzc;|^5&;08x>u-B^;>)8sJ69+DGS&~=P(t#(5cdtl-#86 zNGB&x3YpQ3CVQGCIhmy5>4xV7@KE;bE}Cq)C^Eo@a^`<>`f8Gw1k7<5I_>}~L>xE> zZ0NHV7tCPQD`hgshF>2-!J1 z4`ANe1q+ye?(ORt#1Q}JzICvPo$Cee{kW|c>|%z2eRiya-rWZReBT$cr}n0e+J45r zJn(`ZbmRG#zGw_@kI)Oyw*l88&zs`DahC;__V4K4{&Y4qy4V(6EI;&uTn^xW{NmqK zvVT}}?r3oDu`qgs;3r9KtVT(t3TbINm>`UnT4mgDcy&-&uSrSQIJIs{bxm2c+Tp-z zFY{-oFP10Em%;HH%9Q589!6)&b$WLC9Dbs2xbAgZ6E}^fdK`MIMr@+JHlcqHM5;33 zPA2$@K2Ned5+z Date: Mon, 12 Jan 2026 13:50:45 -0600 Subject: [PATCH 2/2] wip: wasm: api generation tool Signed-off-by: Hank Donnay --- .../matcher/wasm/internal/cmd/mkapi/main.go | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 internal/matcher/wasm/internal/cmd/mkapi/main.go diff --git a/internal/matcher/wasm/internal/cmd/mkapi/main.go b/internal/matcher/wasm/internal/cmd/mkapi/main.go new file mode 100644 index 000000000..5cfc92ceb --- /dev/null +++ b/internal/matcher/wasm/internal/cmd/mkapi/main.go @@ -0,0 +1,222 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "log/slog" + "os" + "os/signal" + "strconv" + "text/template" +) + +func main() { + var code int + var out io.Writer = os.Stdout + defer func() { + if c, ok := out.(io.Closer); ok { + c.Close() + } + if code != 0 { + os.Exit(code) + } + }() + ctx, cancel := signal.NotifyContext(context.Background(), + os.Interrupt) + defer cancel() + + var opts Options + var level slog.LevelVar + flag.BoolFunc("D", "debug output", func(arg string) error { + ok, _ := strconv.ParseBool(arg) + if ok { + level.Set(slog.LevelDebug) + } + return nil + }) + flag.Func("o", "output file (default stdout)", func(arg string) error { + f, err := os.Create(arg) + if err != nil { + return err + } + if out != os.Stdout { + out.(*os.File).Close() + } + out = f + return nil + }) + flag.Parse() + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: &level, + }))) + + if err := Main(ctx, out, opts); err != nil { + slog.ErrorContext(ctx, "failed", "reason", err) + } +} + +type Options struct{} + +func Main(ctx context.Context, w io.Writer, opts Options) error { + return errors.Join( + writeC(ctx, w), + ) +} + +func writeC(_ context.Context, w io.Writer) error { + data := struct { + Arena bool + Decls []cDecl + }{ + Decls: []cDecl{ + {Recv: "detector", Kind: cKindValid}, + {Recv: "detector", Kind: cKindString, Arg: "kind"}, + {Recv: "detector", Kind: cKindString, Arg: "name"}, + {Recv: "detector", Kind: cKindString, Arg: "version"}, + {Recv: "distribution", Kind: cKindValid}, + {Recv: "indexrecord", Kind: cKindRef, Arg: "package"}, + {Recv: "indexrecord", Kind: cKindRef, Arg: "distribution"}, + {Recv: "indexrecord", Kind: cKindRef, Arg: "repository"}, + {Recv: "package", Kind: cKindValid}, + {Recv: "range", Kind: cKindValid}, + {Recv: "repository", Kind: cKindValid}, + {Recv: "version", Kind: cKindValid}, + {Recv: "vulnerability", Kind: cKindRef, Arg: "package"}, + {Recv: "vulnerability", Kind: cKindRef, Arg: "distribution"}, + {Recv: "vulnerability", Kind: cKindRef, Arg: "repository"}, + }, + } + t, err := template.New("C").Parse(cTmpls) + if err != nil { + return err + } + return t.Execute(w, data) +} + +const cTmpls = `// Autogenerated, DO NOT EDIT. +#include +#include +#include + +// Types: +typedef __externref_t detector_ref; +typedef __externref_t distribution_ref; +typedef __externref_t indexrecord_ref; +typedef __externref_t package_ref; +typedef __externref_t range_ref; +typedef __externref_t repository_ref; +typedef __externref_t vulnerability_ref; +typedef uint32_t MatchConstraints; +typedef enum MatchConstraintFlags{ + PackageSourceName = 1<<0, + PackageName = 1<<1, + PackageModule = 1<<2, + DistributionDID = 1<<3, + DistributionName = 1<<4, + DistributionVersion = 1<<5, + DistributionVersionCodeName = 1<<6, + DistributionVersionID = 1<<7, + DistributionArch = 1<<8, + DistributionCPE = 1<<9, + DistributionPrettyName = 1<<10, + RepositoryName = 1<<11, + RepositoryKey = 1<<12, + HasFixedInVersion = 1<<13, +} MatchConstraintFlags; +{{if .Arena}} +// Arena allocator. +// +// Thanks for the inspiration, skeeto: +// - https://nullprogram.com/blog/2023/09/27/ +// - https://nullprogram.com/blog/2025/04/19/ + +// Some magic builtins and compiler wrangling: +size_t __builtin_wasm_memory_size(int); +size_t __builtin_wasm_memory_grow(int, size_t); +extern char __heap_base[]; + +typedef struct { + char *beg; + char *end; +} Arena; + +static Arena getarena(void) +{ +// if(!init){ +// // Allocate our heap. +// __builtin_wasm_memory_grow(0, __builtin_wasm_memory_size(0)); +// init = true; +// } + Arena a = {0}; + a.beg = __heap_base; + a.end = (char *)(__builtin_wasm_memory_size(0) << 16); + return a; +} + +static void *alloc(Arena *a, ptrdiff_t count, ptrdiff_t size, ptrdiff_t align) +{ + ptrdiff_t pad = (ptrdiff_t)-(size_t)a->beg & (align - 1); + char *r = a->beg + pad; + a->beg += pad + count*size; + return __builtin_memset(r, 0, (size_t)(size*count)); +} + +typedef struct { + char *s; + size_t len; + ptrdiff_t cap; +} Str; + +static Str allocStr(Arena *a, ptrdiff_t max) +{ + Str s = {0}; + char *p = alloc(a, 1, max, 4); + s.s = p; + s.cap = max; + return s; +} +{{ end }} +// For use by the caller code: +#define EXPORT(s) __attribute__((export_name(s))) + +// Host interface: +{{range .Decls -}} +{{.Signature}}; +{{end -}} +` + +type cDecl struct { + Recv string + Arg string + Kind cDeclKind +} + +func (d *cDecl) Signature() (string, error) { + var out string + switch d.Kind { + case cKindRef: + const format = `__attribute((import_module("claircore_matcher_1"), import_name("%[1]s_get_%[2]s"))) %[2]s_ref %[1]s_get_%[2]s(%[1]s_ref)` + out = fmt.Sprintf(format, d.Recv, d.Arg) + case cKindString: + const format = `__attribute((import_module("claircore_matcher_1"), import_name("%[1]s_get_%[2]s"))) ptrdiff_t %[1]s_get_%[2]s(%[1]s_ref, char*, size_t)` + out = fmt.Sprintf(format, d.Recv, d.Arg) + case cKindValid: + const format = `__attribute((import_module("claircore_matcher_1"), import_name("%[1]s_valid"))) bool %[1]s_valid(%[1]s_ref)` + out = fmt.Sprintf(format, d.Recv) + default: + return "", errors.New("unknown cDecl kind") + } + return out, nil +} + +type cDeclKind int + +const ( + _ cDeclKind = iota + cKindRef + cKindString + cKindValid +)