1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
// Package wasm provides the wazero runtime for the gonix WASM evaluator.
// It loads libnixexpr.wasm and wires up:
//   - WASI snapshot preview 1 (filesystem, clock, etc.)
//   - "env" module via wazero's emscripten package (invoke_* + our __cxa_* stubs)
//   - "lix_store" module (store I/O dispatch to a Go Store)
package wasm

import (
	"context"
	"fmt"
	"math"
	"os"
	"strings"
	"sync/atomic"
	"time"

	"github.com/nixos/lix/gonix/codec"
	"github.com/nixos/lix/gonix/store"
	"github.com/tetratelabs/wazero"
	"github.com/tetratelabs/wazero/api"
	"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)

// Runtime wraps the wazero runtime and the instantiated libnixexpr module.
type Runtime struct {
	r     wazero.Runtime
	mod   api.Module
	store store.Store

	// Counters tracks host-function call counts, readable after any call.
	Counters Counters

	// fatalErr holds a non-EH trap error (abort, OOB, etc.) that occurred
	// during WASM execution.  Set by callInvoke when it detects a fatal trap.
	// Checked by exported methods before calling WASM functions.
	fatalErr error

	// nixConfig is the nix.conf contents served to the WASM module via
	// lix_host_get_config.  Empty string means no config (use defaults).
	nixConfig string

	// hostTableEntries maps __indirect_function_table slot → "env" import name
	// for every host-imported function that appears in the function table.
	// Built once at init time; used by invoke_* dispatch to short-circuit
	// wazero's LookupFunction for host-module entries (which crashes on wazevo).
	hostTableEntries map[uint32]string

	// envHandlers maps "env" export name → Go handler function.
	// Populated during instantiateEnv so dispatchHostTableEntry can call host
	// functions directly without going through the module API (which forbids
	// ExportedFunction on host modules).
	envHandlers map[string]api.GoModuleFunc

	// trampolineFns caches the GoModuleFunction values from the "_invoke"
	// trampoline module, keyed by invoke_* name.  Extracted via
	// ExportedFunctionDefinitions at init time (safe for host modules).
	// Called directly in the WASM-defined fallback path of invoke_* dispatch.
	trampolineFns map[string]api.GoModuleFunction

	// Exported function handles (cached after instantiation).
	fnNixInit         api.Function
	fnNixBuiltinNames api.Function
	fnNixParseExpr    api.Function
	fnNixEvalExpr     api.Function
	fnNixForceValue   api.Function
	fnNixValueType    api.Function
	fnNixGetString    api.Function
	fnNixGetInt       api.Function
	fnNixGetFloat     api.Function
	fnNixGetBool      api.Function
	fnNixGetAttr      api.Function
	fnNixGetAttrNames api.Function
	fnNixListLength   api.Function
	fnNixListGet      api.Function
	fnNixFreeValue    api.Function
	fnNixLastError    api.Function
	fnNixValuePtr     api.Function
	fnMalloc          api.Function
	fnFree            api.Function
}

// New creates a Runtime from a WASM binary and a backing Store.
// nixConfig is the contents of nix.conf to pass to the evaluator (may be empty).
// compiled selects the native compiler (wazevo) instead of the interpreter.
// The WASM module is instantiated once; call Close() when done.
func New(ctx context.Context, wasmBytes []byte, s store.Store, nixConfig string, useCompiler bool) (*Runtime, error) {
	var rc wazero.RuntimeConfig
	if useCompiler {
		rc = wazero.NewRuntimeConfig()
	} else {
		rc = wazero.NewRuntimeConfigInterpreter()
	}
	rc = rc.WithCoreFeatures(api.CoreFeaturesV2).
		WithDebugInfoEnabled(true)
	r := wazero.NewRuntimeWithConfig(ctx, rc)

	// 1. WASI snapshot preview 1 — handles fd_write, clock_time_get, etc.
	if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil {
		r.Close(ctx)
		return nil, fmt.Errorf("wasi_snapshot_preview1: %w", err)
	}

	rt := &Runtime{r: r, store: s, nixConfig: nixConfig}

	// 2. Compile the WASM module first so we can inspect its imports.
	t0 := time.Now()
	compiled, err := r.CompileModule(ctx, wasmBytes)
	fmt.Fprintf(os.Stderr, "[timing] wasm compile:      %s\n", time.Since(t0).Round(time.Millisecond))
	if err != nil {
		r.Close(ctx)
		return nil, fmt.Errorf("compile libnixexpr: %w", err)
	}

	// 3. Parse function table to find which slots hold host ("env") imports.
	//    This is used by our invoke_* handlers to avoid wazero's LookupFunction
	//    for host-module entries (which crashes in wazevo due to opaque-ptr layout
	//    differences between host and WASM modules).
	hostTableEntries, err := parseHostTableEntries(wasmBytes, compiled)
	if err != nil {
		r.Close(ctx)
		return nil, fmt.Errorf("parse host table entries: %w", err)
	}
	rt.hostTableEntries = hostTableEntries

	// 4. "env" module — our own invoke_* dispatch + __cxa_* + syscall + thread stubs.
	//    A private "_invoke" trampoline module holds wazero's emscripten invoke_*
	//    functions for the WASM-defined-function fallback path.
	if err := rt.instantiateEnv(ctx, compiled); err != nil {
		r.Close(ctx)
		return nil, fmt.Errorf("env module: %w", err)
	}

	// 5. "lix_store" module — store I/O dispatch.
	if err := rt.instantiateLixStore(ctx); err != nil {
		r.Close(ctx)
		return nil, fmt.Errorf("lix_store module: %w", err)
	}

	// 6. Instantiate the WASM module.
	// Use WithStartFunctions() (empty) so wazero doesn't auto-call _initialize.
	// We set rt.mod first, then call _initialize manually — this is required
	// because _initialize runs C++ global constructors which may call
	// __resumeException / __cxa_find_matching_catch, which need rt.mod set
	// to reach libnixexpr's exported helper functions (setThrew, tempret_set).
	cfg := wazero.NewModuleConfig().
		WithName("libnixexpr").
		WithStdout(os.Stdout).
		WithStderr(os.Stderr).
		// Mount the real filesystem so `import ./foo.nix` can read files.
		WithFSConfig(wazero.NewFSConfig().WithDirMount("/", "/")).
		WithStartFunctions()

	t1 := time.Now()
	mod, err := r.InstantiateModule(ctx, compiled, cfg)
	if err != nil {
		r.Close(ctx)
		return nil, fmt.Errorf("instantiate libnixexpr: %w", err)
	}
	// Set rt.mod before _initialize so EH stubs can reach libnixexpr exports.
	rt.mod = mod

	if initFn := mod.ExportedFunction("_initialize"); initFn != nil {
		if _, err := initFn.Call(ctx); err != nil {
			r.Close(ctx)
			return nil, fmt.Errorf("_initialize: %w", err)
		}
	}
	fmt.Fprintf(os.Stderr, "[timing] wasm instantiate:  %s\n", time.Since(t1).Round(time.Millisecond))

	// Cache exported functions.
	rt.fnNixInit = mod.ExportedFunction("nix_init")
	rt.fnNixBuiltinNames = mod.ExportedFunction("nix_builtin_names")
	rt.fnNixParseExpr = mod.ExportedFunction("nix_parse_expr")
	rt.fnNixEvalExpr = mod.ExportedFunction("nix_eval_expr")
	rt.fnNixForceValue = mod.ExportedFunction("nix_force_value")
	rt.fnNixValueType = mod.ExportedFunction("nix_value_type")
	rt.fnNixGetString = mod.ExportedFunction("nix_get_string")
	rt.fnNixGetInt = mod.ExportedFunction("nix_get_int")
	rt.fnNixGetFloat = mod.ExportedFunction("nix_get_float")
	rt.fnNixGetBool = mod.ExportedFunction("nix_get_bool")
	rt.fnNixGetAttr = mod.ExportedFunction("nix_get_attr")
	rt.fnNixGetAttrNames = mod.ExportedFunction("nix_get_attr_names")
	rt.fnNixListLength = mod.ExportedFunction("nix_list_length")
	rt.fnNixListGet = mod.ExportedFunction("nix_list_get")
	rt.fnNixFreeValue = mod.ExportedFunction("nix_free_value")
	rt.fnNixLastError = mod.ExportedFunction("nix_last_error")
	rt.fnNixValuePtr = mod.ExportedFunction("nix_value_ptr")
	rt.fnMalloc = mod.ExportedFunction("malloc")
	rt.fnFree = mod.ExportedFunction("free")

	return rt, nil
}

// fatalCheck returns fatalErr if set, otherwise nil.
func (rt *Runtime) fatalCheck() error {
	if rt.fatalErr != nil {
		return fmt.Errorf("wasm fatal: %w", rt.fatalErr)
	}
	return nil
}

// HasFatalError reports whether an unrecoverable trap has been recorded.
// After a fatal error all subsequent WASM calls will fail.
func (rt *Runtime) HasFatalError() bool {
	return rt.fatalErr != nil
}

// Init calls nix_init with the given store directory (e.g. "/nix/store").
func (rt *Runtime) Init(ctx context.Context, storeDir string) error {
	ptr, err := rt.allocString(ctx, storeDir)
	if err != nil {
		return err
	}
	defer rt.freePtr(ctx, ptr)

	_, err = rt.fnNixInit.Call(ctx, uint64(ptr), uint64(len(storeDir)))
	if err != nil {
		return fmt.Errorf("nix_init: %w", err)
	}
	return nil
}

// ParseExpr parses a Nix expression string and returns the post-finalize AST
// encoded in the lix codec wire format. The caller decodes it with eval.DecodeAST.
//
// basePath is the directory of the file being parsed (e.g. "/testfs/sub/").
// Relative path literals like ./foo.nix are resolved against basePath.
// Pass "/" for the legacy root-relative behaviour.
//
// filePath is the absolute path of the file being parsed (e.g. "/testfs/sub/foo.nix"),
// used only for position information. Pass "" if unknown.
func (rt *Runtime) ParseExpr(ctx context.Context, expr string, basePath string, filePath string) ([]byte, error) {
	if err := rt.fatalCheck(); err != nil {
		return nil, err
	}
	if rt.fnNixParseExpr == nil {
		return nil, fmt.Errorf("nix_parse_expr not available in this WASM build")
	}
	if basePath == "" {
		basePath = "/"
	}

	exprPtr, err := rt.allocString(ctx, expr)
	if err != nil {
		return nil, err
	}
	defer rt.freePtr(ctx, exprPtr)

	basePathPtr, err := rt.allocString(ctx, basePath)
	if err != nil {
		return nil, err
	}
	defer rt.freePtr(ctx, basePathPtr)

	filePathPtr, err := rt.allocString(ctx, filePath)
	if err != nil {
		return nil, err
	}
	defer rt.freePtr(ctx, filePathPtr)

	// Allocate a response buffer (32 MiB — sufficient for large nixpkgs files).
	const respMax = 32 * 1024 * 1024
	respPtr, err := rt.malloc(ctx, respMax)
	if err != nil {
		return nil, err
	}
	defer rt.freePtr(ctx, respPtr)

	res, err := rt.fnNixParseExpr.Call(ctx,
		uint64(exprPtr), uint64(len(expr)),
		uint64(basePathPtr), uint64(len(basePath)),
		uint64(filePathPtr), uint64(len(filePath)),
		uint64(respPtr), uint64(respMax),
	)
	if err != nil {
		// A C++ exception escaped the WASM boundary without going through the
		// normal invoke_* EH protocol.  Reset EH globals so the runtime is
		// usable for subsequent calls.
		rt.resetEHState(ctx)
		return nil, fmt.Errorf("nix_parse_expr: %w", err)
	}
	n := int32(res[0])
	if n < 0 {
		return nil, fmt.Errorf("parse error: %s", rt.lastError(ctx))
	}
	b, ok := rt.mod.Memory().Read(respPtr, uint32(n))
	if !ok {
		return nil, fmt.Errorf("nix_parse_expr: memory read failed")
	}
	// Return a copy so the WASM memory can be freed.
	out := make([]byte, len(b))
	copy(out, b)
	return out, nil
}

// BuiltinNames returns the ordered list of builtin names from the C++ base
// environment. The order matches ExprVar.Displ values resolved by nix_parse_expr,
// so the Go evaluator must register builtins in this exact order.
func (rt *Runtime) BuiltinNames(ctx context.Context) ([]string, error) {
	if err := rt.fatalCheck(); err != nil {
		return nil, err
	}
	if rt.fnNixBuiltinNames == nil {
		return nil, fmt.Errorf("nix_builtin_names not available in this WASM build")
	}

	const respMax = 64 * 1024 // 64 KiB — more than enough for builtin names
	respPtr, err := rt.malloc(ctx, respMax)
	if err != nil {
		return nil, err
	}
	defer rt.freePtr(ctx, respPtr)

	res, err := rt.fnNixBuiltinNames.Call(ctx, uint64(respPtr), uint64(respMax))
	if err != nil {
		return nil, fmt.Errorf("nix_builtin_names: %w", err)
	}
	n := int32(res[0])
	if n < 0 {
		return nil, fmt.Errorf("nix_builtin_names error: %s", rt.lastError(ctx))
	}
	b, ok := rt.mod.Memory().Read(respPtr, uint32(n))
	if !ok {
		return nil, fmt.Errorf("nix_builtin_names: memory read failed")
	}

	v, err := codec.Decode(b)
	if err != nil {
		return nil, fmt.Errorf("nix_builtin_names: decode: %w", err)
	}
	if v.Kind != codec.KindList {
		return nil, fmt.Errorf("nix_builtin_names: expected list, got kind %d", v.Kind)
	}
	names := make([]string, 0, len(v.Items))
	for i, item := range v.Items {
		s, err := item.AsString()
		if err != nil {
			return nil, fmt.Errorf("nix_builtin_names: item[%d]: %w", i, err)
		}
		names = append(names, s)
	}
	return names, nil
}

// EvalExpr evaluates a Nix expression string and returns a handle.
func (rt *Runtime) EvalExpr(ctx context.Context, expr string) (int32, error) {
	if err := rt.fatalCheck(); err != nil {
		return -1, err
	}
	ptr, err := rt.allocString(ctx, expr)
	if err != nil {
		return -1, err
	}
	defer rt.freePtr(ctx, ptr)

	res, err := rt.fnNixEvalExpr.Call(ctx, uint64(ptr), uint64(len(expr)))
	if err != nil {
		rt.resetEHState(ctx)
		return -1, fmt.Errorf("nix_eval_expr: %w", err)
	}
	handle := int32(res[0])
	if handle < 0 {
		return -1, fmt.Errorf("eval error: %s", rt.lastError(ctx))
	}
	return handle, nil
}

// ForceValue forces a value to WHNF. Returns the same handle on success.
func (rt *Runtime) ForceValue(ctx context.Context, handle int32) error {
	if err := rt.fatalCheck(); err != nil {
		return err
	}
	res, err := rt.fnNixForceValue.Call(ctx, uint64(handle))
	if err != nil {
		rt.resetEHState(ctx)
		return fmt.Errorf("nix_force_value: %w", err)
	}
	if int32(res[0]) < 0 {
		return fmt.Errorf("force error: %s", rt.lastError(ctx))
	}
	return nil
}

// ValueType returns the ValueType of a forced value.
func (rt *Runtime) ValueType(ctx context.Context, handle int32) (int32, error) {
	res, err := rt.fnNixValueType.Call(ctx, uint64(handle))
	if err != nil {
		return -1, err
	}
	return int32(res[0]), nil
}

// ValuePtr returns the WASM linear-memory address of the Value* backing the
// handle.  Two handles pointing to the same Nix Value object return the same
// address.  Used by the printer for cycle/repeat detection (mirrors cppnix's
// pointer-identity ValuesSeen set).  Returns 0 on invalid handle.
func (rt *Runtime) ValuePtr(ctx context.Context, handle int32) (uint32, error) {
	if rt.fnNixValuePtr == nil {
		return 0, nil // older WASM without the export — degrade gracefully
	}
	res, err := rt.fnNixValuePtr.Call(ctx, uint64(handle))
	if err != nil {
		return 0, err
	}
	return uint32(res[0]), nil
}

// GetString reads a string value from a handle.
func (rt *Runtime) GetString(ctx context.Context, handle int32) (string, error) {
	return rt.callStringOut(ctx, rt.fnNixGetString, handle)
}

// GetInt reads an integer value.
func (rt *Runtime) GetInt(ctx context.Context, handle int32) (int64, error) {
	res, err := rt.fnNixGetInt.Call(ctx, uint64(handle))
	if err != nil {
		return 0, err
	}
	return int64(res[0]), nil
}

// GetFloat reads a float value.
func (rt *Runtime) GetFloat(ctx context.Context, handle int32) (float64, error) {
	res, err := rt.fnNixGetFloat.Call(ctx, uint64(handle))
	if err != nil {
		return 0, err
	}
	return math.Float64frombits(res[0]), nil
}

// GetBool reads a boolean value.
func (rt *Runtime) GetBool(ctx context.Context, handle int32) (bool, error) {
	res, err := rt.fnNixGetBool.Call(ctx, uint64(handle))
	if err != nil {
		return false, err
	}
	return res[0] != 0, nil
}

// GetAttr looks up an attribute by name and returns a new handle.
func (rt *Runtime) GetAttr(ctx context.Context, handle int32, name string) (int32, error) {
	namePtr, err := rt.allocString(ctx, name)
	if err != nil {
		return -1, err
	}
	defer rt.freePtr(ctx, namePtr)

	res, err := rt.fnNixGetAttr.Call(ctx, uint64(handle), uint64(namePtr), uint64(len(name)))
	if err != nil {
		return -1, err
	}
	h := int32(res[0])
	if h < 0 {
		return -1, fmt.Errorf("attr %q not found: %s", name, rt.lastError(ctx))
	}
	return h, nil
}

// GetAttrNames returns all attribute names as a newline-separated string.
func (rt *Runtime) GetAttrNames(ctx context.Context, handle int32) (string, error) {
	return rt.callStringOut(ctx, rt.fnNixGetAttrNames, handle)
}

// ListLength returns the length of a list value.
func (rt *Runtime) ListLength(ctx context.Context, handle int32) (int32, error) {
	res, err := rt.fnNixListLength.Call(ctx, uint64(handle))
	if err != nil {
		return -1, err
	}
	return int32(res[0]), nil
}

// ListGet returns the element at index idx as a new handle.
func (rt *Runtime) ListGet(ctx context.Context, handle int32, idx int32) (int32, error) {
	res, err := rt.fnNixListGet.Call(ctx, uint64(handle), uint64(idx))
	if err != nil {
		return -1, err
	}
	h := int32(res[0])
	if h < 0 {
		return -1, fmt.Errorf("list index %d out of range", idx)
	}
	return h, nil
}

// FreeValue releases a handle.
func (rt *Runtime) FreeValue(ctx context.Context, handle int32) {
	rt.fnNixFreeValue.Call(ctx, uint64(handle)) //nolint:errcheck
}

// counterFor returns the appropriate aggregate atomic counter for a host function name.
// For invoke_* this returns InvokeTotal; fine-grained InvokeHost/InvokeWasm and
// CxaThrow are incremented separately in callInvoke and dispatchHostTableEntry.
func (rt *Runtime) counterFor(name string) *atomic.Int64 {
	switch {
	case strings.HasPrefix(name, "invoke_"):
		return &rt.Counters.InvokeTotal
	case strings.HasPrefix(name, "__cxa_") ||
		name == "__resumeException" ||
		name == "llvm_eh_typeid_for":
		return &rt.Counters.CxaTotal
	case strings.HasPrefix(name, "__syscall_"):
		return &rt.Counters.Syscall
	default:
		return &rt.Counters.Other
	}
}

// Close shuts down the runtime.
func (rt *Runtime) Close(ctx context.Context) error {
	return rt.r.Close(ctx)
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

const bufSize = 65536 // 64 KiB response buffer

// callStringOut calls a function with (handle, bufPtr, bufMax) -> len
// and returns the string written into the buffer.
func (rt *Runtime) callStringOut(ctx context.Context, fn api.Function, handle int32) (string, error) {
	bufPtr, err := rt.malloc(ctx, bufSize)
	if err != nil {
		return "", err
	}
	defer rt.freePtr(ctx, bufPtr)

	res, err := fn.Call(ctx, uint64(handle), uint64(bufPtr), bufSize)
	if err != nil {
		return "", err
	}
	n := int32(res[0])
	if n < 0 {
		return "", fmt.Errorf("%s", rt.lastError(ctx))
	}
	b, ok := rt.mod.Memory().Read(bufPtr, uint32(n))
	if !ok {
		return "", fmt.Errorf("memory read failed")
	}
	return string(b), nil
}

func (rt *Runtime) lastError(ctx context.Context) string {
	bufPtr, err := rt.malloc(ctx, 4096)
	if err != nil {
		return "(failed to allocate error buffer)"
	}
	defer rt.freePtr(ctx, bufPtr)

	res, err := rt.fnNixLastError.Call(ctx, uint64(bufPtr), 4096)
	if err != nil {
		return fmt.Sprintf("(nix_last_error failed: %v)", err)
	}
	if int32(res[0]) <= 0 {
		return fmt.Sprintf("(nix_last_error returned %d — g_last_error is empty)", int32(res[0]))
	}
	n := uint32(res[0])
	b, ok := rt.mod.Memory().Read(bufPtr, n)
	if !ok {
		return "(memory read failed)"
	}
	return string(b)
}

// allocString writes s into WASM linear memory as a null-terminated C string
// and returns the pointer.
func (rt *Runtime) allocString(ctx context.Context, s string) (uint32, error) {
	ptr, err := rt.malloc(ctx, uint32(len(s)+1))
	if err != nil {
		return 0, err
	}
	if !rt.mod.Memory().WriteString(ptr, s) {
		rt.freePtr(ctx, ptr)
		return 0, fmt.Errorf("memory write failed")
	}
	if !rt.mod.Memory().WriteByte(ptr+uint32(len(s)), 0) {
		rt.freePtr(ctx, ptr)
		return 0, fmt.Errorf("memory write null terminator failed")
	}
	return ptr, nil
}

func (rt *Runtime) malloc(ctx context.Context, size uint32) (uint32, error) {
	res, err := rt.fnMalloc.Call(ctx, uint64(size))
	if err != nil {
		return 0, fmt.Errorf("malloc: %w", err)
	}
	if res[0] == 0 {
		return 0, fmt.Errorf("malloc returned null")
	}
	return uint32(res[0]), nil
}

func (rt *Runtime) freePtr(ctx context.Context, ptr uint32) {
	rt.fnFree.Call(ctx, uint64(ptr)) //nolint:errcheck
}