synth-cli 0.64.0

CLI for Synth, the WebAssembly-to-ARM Cortex-M AOT compiler
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
//! #406 (VCR-MEM-002 phase 1) — multi-memory capability matrix.
//!
//! N wasm linear memories lower to N DISTINCT native base regions on the ARM
//! `--relocatable` path: memory 0 keeps the runtime R11 base (single-memory
//! lowering untouched — the frozen byte gate pins that), memory k > 0 is
//! addressed via its own `__synth_wasm_data_<k>` symbol at the base of a
//! per-memory `.synth.wasm_mem_<k>` reservation section.
//!
//! Everything OUTSIDE that lane must decline LOUDLY (typed, precise), never
//! silently alias every memory onto one base (the pre-#406 bug):
//!
//! | shape                                  | verdict                        |
//! |----------------------------------------|--------------------------------|
//! | 2 memories, ARM `--relocatable`        | GREEN (execution differential) |
//! | 3 memories, ARM `--relocatable`        | GREEN (generic over K)         |
//! | multi-memory, no `--relocatable`       | refuse (one R11 base)          |
//! | multi-memory, self-contained --cortex-m| refuse (same)                  |
//! | multi-memory × `--native-pointer-abi`  | refuse (static region is mem-0)|
//! | multi-memory × `--shadow-stack-size`   | refuse (shrinks mem-0 geometry)|
//! | multi-memory × `--safety-bounds mpu`   | refuse (MPU-startup interlock)  |
//! | multi-memory on riscv / aarch64        | refuse (no per-memory base)    |
//! | cross-/non-default memory.copy/fill    | loud-skip naming the op        |
//! | i64/f32 access on memory k > 0         | loud-skip (i32 family only)    |
//! | non-const data-segment offset on mem k | refuse at decode               |
//!
//! Execution ground truth: `scripts/repro/multi_memory_406_differential.py`
//! (unicorn two-region mapping vs wasmtime multi-memory).

use std::path::PathBuf;
use std::process::Command;

use object::{Object, ObjectSection, ObjectSymbol, SectionKind};

// #977 RQ-59-FRESHNESS: nothing here parses an artifact until the artifact is
// proven to be THIS invocation's output — see `artifact_guard`.
mod artifact_guard;

fn synth() -> &'static str {
    env!("CARGO_BIN_EXE_synth")
}

/// The differential's fixture — 2 memories, init data on memory 1.
fn two_mem_fixture() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../..")
        .join("scripts/repro/mem406_multi_memory.wat")
}

/// Write an inline wat to a temp file and return its path.
fn wat_file(name: &str, wat: &str) -> PathBuf {
    let dir = std::env::temp_dir().join("synth_mem406_tests");
    std::fs::create_dir_all(&dir).expect("mkdir");
    let p = dir.join(name);
    std::fs::write(&p, wat).expect("write wat");
    p
}

fn compile(input: &std::path::Path, extra: &[&str]) -> std::process::Output {
    let out = std::env::temp_dir()
        .join("synth_mem406_tests")
        .join(format!(
            "{}_{}.o",
            input.file_stem().unwrap().to_str().unwrap(),
            extra.join("").replace(['-', '/', ' '], "")
        ));
    let mut args = vec![
        "compile",
        input.to_str().unwrap(),
        "--all-exports",
        "-o",
        out.to_str().unwrap(),
    ];
    args.extend_from_slice(extra);
    Command::new(synth())
        .args(&args)
        .output()
        .expect("run synth")
}

/// #977: the guarded READ-BACK form — compile and hand back the object bytes
/// proven to be this invocation's output (unique path + remove-first +
/// status/exists/non-empty via `artifact_guard`). [`compile`] above stays for
/// the refusal tests, which never read an artifact.
fn compile_read(input: &std::path::Path, extra: &[&str]) -> Vec<u8> {
    let out = artifact_guard::unique_artifact(
        &format!("mem406_{}", input.file_stem().unwrap().to_str().unwrap()),
        "o",
    );
    let mut args = vec![
        "compile",
        input.to_str().unwrap(),
        "--all-exports",
        "-o",
        out.to_str().unwrap(),
    ];
    args.extend_from_slice(extra);
    let mut cmd = Command::new(synth());
    cmd.args(&args);
    artifact_guard::compile_bytes_or_panic(
        &mut cmd,
        &out,
        input.file_stem().unwrap().to_str().unwrap(),
    )
}

fn stderr(out: &std::process::Output) -> String {
    String::from_utf8_lossy(&out.stderr).into_owned()
}

/// A refusal must be loud AND precise: nonzero exit + names multi-memory/#406.
fn assert_refused(out: &std::process::Output, must_mention: &[&str], ctx: &str) {
    assert!(
        !out.status.success(),
        "{ctx}: expected a loud refusal, got success.\nstderr: {}",
        stderr(out)
    );
    let err = stderr(out);
    for needle in must_mention {
        assert!(
            err.contains(needle),
            "{ctx}: refusal does not mention '{needle}'.\nstderr: {err}"
        );
    }
}

// ---------------------------------------------------------------------------
// GREEN lane
// ---------------------------------------------------------------------------

/// 2 memories on ARM --relocatable: per-memory region + symbol, init data
/// placed. (Execution equivalence is the python differential's job.)
#[test]
fn two_memories_relocatable_green() {
    let bytes = compile_read(
        &two_mem_fixture(),
        &["--relocatable", "--target", "cortex-m3"],
    );
    let obj = object::File::parse(&*bytes).expect("parse ELF");

    // Memory 1's region: PROGBITS (has an init segment), exactly 3 pages,
    // segment bytes placed at offset 16.
    let mem1 = obj
        .section_by_name(".synth.wasm_mem_1")
        .expect("memory 1 reservation section");
    assert_eq!(mem1.size(), 3 * 65536, "3 declared pages");
    let data = mem1.data().expect("progbits data");
    assert_eq!(
        &data[16..24],
        b"\xaa\xbb\xcc\xdd\x11\x22\x33\x44",
        "init segment placed at its offset"
    );

    // Its base symbol is DEFINED and points at that section.
    let sym = obj
        .symbols()
        .find(|s| s.name() == Ok("__synth_wasm_data_1"))
        .expect("__synth_wasm_data_1 defined");
    assert!(!sym.is_undefined(), "must be a defined region base");
    assert_eq!(sym.address(), 0, "base of the section");

    // Memory 0 keeps the historical addressing: NO __synth_wasm_data_0 alias.
    assert!(
        !obj.symbols().any(|s| s.name() == Ok("__synth_wasm_data_0")),
        "memory 0 keeps the R11/legacy contract — no _0 symbol"
    );
}

/// 3 memories: generic over K — memory 1 (pure zero-init) ships NOBITS,
/// memory 2 (init segment) ships PROGBITS, each under its own symbol.
#[test]
fn three_memories_relocatable_green() {
    let wat = r#"(module
      (memory $a 1 1)
      (memory $b 2 2)
      (memory $c 1 1)
      (data (memory $c) (i32.const 8) "\01\02\03\04")
      (func (export "xfer") (param $p i32) (result i32)
        (i32.store $b (local.get $p) (i32.load $c offset=8 (local.get $p)))
        (i32.load $b (local.get $p))))"#;
    let f = wat_file("three_mem.wat", wat);
    let bytes = compile_read(&f, &["--relocatable", "--target", "cortex-m3"]);
    let obj = object::File::parse(&*bytes).expect("parse ELF");

    let mem1 = obj
        .section_by_name(".synth.wasm_mem_1")
        .expect("mem 1 region");
    assert_eq!(mem1.size(), 2 * 65536);
    assert_eq!(
        mem1.kind(),
        SectionKind::UninitializedData,
        "zero-init memory ships NOBITS (no flash cost)"
    );
    let mem2 = obj
        .section_by_name(".synth.wasm_mem_2")
        .expect("mem 2 region");
    assert_eq!(mem2.size(), 65536);
    assert_eq!(&mem2.data().expect("progbits")[8..12], b"\x01\x02\x03\x04");

    for name in ["__synth_wasm_data_1", "__synth_wasm_data_2"] {
        assert!(
            obj.symbols()
                .any(|s| s.name() == Ok(name) && !s.is_undefined()),
            "{name} must be defined"
        );
    }
}

/// RQ-62-MEMISOLATE (#1145, option 3): a multi-memory object carries the
/// per-memory REGION TABLE — `__synth_mem_size_N` (SHN_ABS, declared initial
/// bytes), `__synth_mem_base_k` (k >= 1, base of memory k's section, st_size =
/// region extent) and `__synth_mem_count` — from which the EMBEDDER programs
/// one MPU region per memory. Deliberately NO `__synth_mem_base_0`: memory 0's
/// base is the R11 value the embedder itself chooses, and a link-time symbol
/// would duplicate the register's truth and could silently disagree with it
/// (deliberately phrased to stay out of the RQ-58 obligation-marker counts:
/// this test pins the ABSENCE of such an obligation, not one).
#[test]
fn region_table_symbols_1145() {
    let bytes = compile_read(
        &two_mem_fixture(),
        &["--relocatable", "--target", "cortex-m3"],
    );
    let obj = object::File::parse(&*bytes).expect("parse ELF");

    let abs_value = |name: &str| -> u64 {
        let s = obj
            .symbols()
            .find(|s| s.name() == Ok(name))
            .unwrap_or_else(|| panic!("{name} must be emitted (#1145)"));
        assert_eq!(
            s.section(),
            object::SymbolSection::Absolute,
            "{name} must be SHN_ABS — a link-invariant value, not a placed address"
        );
        s.address()
    };
    assert_eq!(abs_value("__synth_mem_count"), 2, "two memories");
    assert_eq!(abs_value("__synth_mem_size_0"), 65536, "mem 0: 1 page");
    assert_eq!(abs_value("__synth_mem_size_1"), 3 * 65536, "mem 1: 3 pages");

    // The base symbol is section-relative: its LINKED address is the region
    // base, wherever the embedder's linker script places the section.
    let base1 = obj
        .symbols()
        .find(|s| s.name() == Ok("__synth_mem_base_1"))
        .expect("__synth_mem_base_1 must be emitted (#1145)");
    assert!(!base1.is_undefined(), "defined, not an embedder obligation");
    assert_eq!(base1.address(), 0, "base of .synth.wasm_mem_1");
    assert_eq!(base1.size(), 3 * 65536, "st_size carries the region extent");
    let base1_sec = match base1.section() {
        object::SymbolSection::Section(idx) => obj
            .section_by_index(idx)
            .expect("resolvable section")
            .name()
            .expect("named")
            .to_string(),
        other => panic!("__synth_mem_base_1 must be section-relative, got {other:?}"),
    };
    assert_eq!(base1_sec, ".synth.wasm_mem_1");

    assert!(
        !obj.symbols().any(|s| s.name() == Ok("__synth_mem_base_0")),
        "NO __synth_mem_base_0 — memory 0's base is the embedder's R11 value \
         (one source of truth, docs/embedder-abi-relocatable-arm.md)"
    );
}

/// The region table is multi-memory-only: a single-memory relocatable object
/// carries NONE of the #1145 symbols (frozen single-memory anchors must not
/// move — verified byte-identical at authoring across the relocatable,
/// software-bounds and self-contained paths).
#[test]
fn region_table_absent_on_single_memory_1145() {
    let wat = r#"(module
      (memory 1)
      (func (export "rd") (param i32) (result i32) (i32.load8_u (local.get 0))))"#;
    let f = wat_file("single_mem_1145.wat", wat);
    let bytes = compile_read(&f, &["--relocatable", "--target", "cortex-m3"]);
    let obj = object::File::parse(&*bytes).expect("parse ELF");
    for s in obj.symbols() {
        let name = s.name().unwrap_or("");
        assert!(
            !name.starts_with("__synth_mem_"),
            "single-memory object must carry no region-table symbol, found {name}"
        );
    }
}

// ---------------------------------------------------------------------------
// Decline matrix
// ---------------------------------------------------------------------------

/// No --relocatable ⇒ one runtime base (R11) ⇒ refuse the whole module.
#[test]
fn multi_memory_without_relocatable_refuses() {
    let out = compile(&two_mem_fixture(), &["--target", "cortex-m3"]);
    assert_refused(
        &out,
        &["multi-memory", "#406", "--relocatable"],
        "plain object path",
    );
}

/// Self-contained --cortex-m image: same single-base refusal.
#[test]
fn multi_memory_self_contained_cortex_m_refuses() {
    let out = compile(&two_mem_fixture(), &["--cortex-m"]);
    assert_refused(&out, &["multi-memory", "#406"], "self-contained --cortex-m");
}

/// --native-pointer-abi: the static-region classification is memory-0-only.
#[test]
fn multi_memory_native_pointer_abi_refuses() {
    let out = compile(
        &two_mem_fixture(),
        &[
            "--relocatable",
            "--native-pointer-abi",
            "--target",
            "cortex-m3",
        ],
    );
    assert_refused(
        &out,
        &["multi-memory", "#406", "--native-pointer-abi"],
        "native-pointer ABI",
    );
}

/// --shadow-stack-size shrinks MEMORY 0's reservation geometry — undefined
/// for a multi-memory module in phase 1.
#[test]
fn multi_memory_shadow_stack_refuses() {
    let out = compile(
        &two_mem_fixture(),
        &[
            "--relocatable",
            "--native-pointer-abi",
            "--shadow-stack-size",
            "2048",
            "--target",
            "cortex-m3",
        ],
    );
    // The native-pointer-abi gate may fire first — either way it must be a
    // loud multi-memory refusal, and the flag combo must never compile.
    assert_refused(&out, &["multi-memory", "#406"], "--shadow-stack-size combo");
}

/// --safety-bounds mpu: per-memory MPU isolation is an architectural interlock
/// (Lane B). Programming one MPU region per memory needs synth to emit the
/// startup that writes MPU_RBAR/RASR — the SELF-CONTAINED reset handler — but
/// multi-memory only compiles on --relocatable (host owns startup, synth emits
/// no MPU programming), and the self-contained path declines multi-memory (one
/// R11 base). So no path both emits synth's startup AND lowers > 1 memory; the
/// cross-memory OOB fault gate cannot be armed. Refuse rather than accept a
/// silent MPU no-op. #1145 (option 3) ships the per-memory REGION TABLE the
/// embedder programs the MPU from; the flag itself stays refused until the
/// two-tenant fault criterion has executed on an MPU-bearing venue.
#[test]
fn multi_memory_safety_bounds_mpu_refuses() {
    let out = compile(
        &two_mem_fixture(),
        &[
            "--relocatable",
            "--safety-bounds",
            "mpu",
            "--target",
            "cortex-m3",
        ],
    );
    // #1145: the refusal no longer does tracker duty for the closed "#406
    // phase 2" — it names the shipped region table (the embedder's MPU
    // programming input), where the obligation is documented, and what gates
    // acceptance (the two-tenant cross-region fault criterion).
    assert_refused(
        &out,
        &[
            "multi-memory",
            "#406",
            "mpu",
            "#1145",
            "__synth_mem_base_N",
            "docs/embedder-abi-relocatable-arm.md",
            "two-tenant",
        ],
        "per-memory MPU isolation interlock",
    );
    let err = stderr(&out);
    assert!(
        !err.contains("#406 phase 2"),
        "refusal must not point at the closed #406 as a tracker: {err}"
    );
}

/// riscv / aarch64: no per-memory base lowering — refuse the module.
#[test]
fn multi_memory_riscv_refuses() {
    let out = compile(&two_mem_fixture(), &["-b", "riscv", "--relocatable"]);
    assert_refused(&out, &["multi-memory", "#406", "riscv"], "riscv backend");
}

#[test]
fn multi_memory_aarch64_refuses() {
    let out = compile(&two_mem_fixture(), &["-b", "aarch64", "--relocatable"]);
    assert_refused(
        &out,
        &["multi-memory", "#406", "aarch64"],
        "aarch64 backend",
    );
}

/// Cross-memory memory.copy has no phase-1 lowering: the function loud-skips
/// naming the op, and a module with only that export fails (never a silent
/// memory-0 copy).
#[test]
fn cross_memory_copy_loud_skips() {
    let wat = r#"(module
      (memory $a 1 1)
      (memory $b 1 1)
      (func (export "xcopy") (param $d i32) (param $s i32) (param $n i32)
        (memory.copy $b $a (local.get $d) (local.get $s) (local.get $n))))"#;
    let f = wat_file("cross_copy.wat", wat);
    let out = compile(&f, &["--relocatable", "--target", "cortex-m3"]);
    assert_refused(&out, &["memory.copy", "#406"], "cross-memory memory.copy");
}

/// memory.fill on a non-default memory: same loud-skip contract.
#[test]
fn non_default_memory_fill_loud_skips() {
    let wat = r#"(module
      (memory $a 1 1)
      (memory $b 1 1)
      (func (export "fill1") (param $d i32) (param $v i32) (param $n i32)
        (memory.fill $b (local.get $d) (local.get $v) (local.get $n))))"#;
    let f = wat_file("fill1.wat", wat);
    let out = compile(&f, &["--relocatable", "--target", "cortex-m3"]);
    assert_refused(&out, &["memory.fill", "#406"], "non-default memory.fill");
}

/// Phase-1 scope is the i32 access family: an i64 access on memory k > 0
/// loud-skips its function (never aliases or truncates).
#[test]
fn wide_access_on_non_default_memory_loud_skips() {
    let wat = r#"(module
      (memory $a 1 1)
      (memory $b 1 1)
      (func (export "w") (param $p i32) (result i64)
        (i64.load $b (local.get $p))))"#;
    let f = wat_file("wide1.wat", wat);
    let out = compile(&f, &["--relocatable", "--target", "cortex-m3"]);
    assert_refused(&out, &["#406"], "i64 access on memory 1");
    assert!(
        stderr(&out).contains("i32 load/store family"),
        "must name the phase-1 scope.\nstderr: {}",
        stderr(&out)
    );
}

/// An active data segment on memory k > 0 with a NON-constant offset cannot
/// be placed at compile time — refuse at decode, never ship memory k
/// uninitialized.
#[test]
fn non_const_segment_offset_on_memory_k_refuses() {
    let wat = r#"(module
      (import "env" "off" (global $off i32))
      (memory $a 1 1)
      (memory $b 1 1)
      (data (memory $b) (global.get $off) "\aa\bb")
      (func (export "f") (param $p i32) (result i32)
        (i32.load $b (local.get $p))))"#;
    let f = wat_file("nonconst_seg.wat", wat);
    let out = compile(&f, &["--relocatable", "--target", "cortex-m3"]);
    assert_refused(
        &out,
        &["multi-memory", "#406", "non-constant offset"],
        "non-const segment offset",
    );
}

/// A segment overflowing memory k's declared size would trap at
/// instantiation — refuse rather than truncate.
#[test]
fn segment_overflowing_memory_k_refuses() {
    let wat = r#"(module
      (memory $a 1 1)
      (memory $b 1 1)
      (data (memory $b) (i32.const 65532) "\01\02\03\04\05\06\07\08")
      (func (export "f") (param $p i32) (result i32)
        (i32.load $b (local.get $p))))"#;
    let f = wat_file("overflow_seg.wat", wat);
    let out = compile(&f, &["--relocatable", "--target", "cortex-m3"]);
    assert_refused(
        &out,
        &["multi-memory", "#406", "overflows"],
        "overflowing segment",
    );
}

/// #977 RQ-59-FRESHNESS — the SILENT-direction demonstration for the
/// structure/linkability batch (multi_memory_406, dwarf_debug_line_emit_394,
/// elf_tooling_637_656, heterogeneous_table_676, cabi_arena_bind_418,
/// cabi_arena_realloc_linkability_418, call_indirect_275_selfcontained,
/// async_intrinsics_gate, provenance_* — all now compile through
/// `artifact_guard`).
///
/// Prove the counterfactual: a planted VALID relocatable object (minted by
/// this file's own compile shape) parses and carries the `.synth.wasm_mem_1`
/// section this file's gates assert on — so the pre-conversion path-based
/// `fs::read` + `File::parse` WOULD have re-confirmed last run's structure as
/// this run's evidence. Then fail the compile at that path and require the
/// guard to refuse AND to leave nothing behind.
#[test]
fn freshness_guard_refuses_stale_structure_artifact_977() {
    // 1. Mint a genuine object with this batch's own compile shape.
    let good = compile_read(
        &two_mem_fixture(),
        &["--relocatable", "--target", "cortex-m3"],
    );

    // 2. Plant it, and prove the OLD shape would have passed on it.
    let planted_at = artifact_guard::unique_artifact("mem406_stale_planted", "o");
    std::fs::write(&planted_at, &good).expect("plant the stale artifact");
    let stale = std::fs::read(&planted_at).expect("read planted");
    let obj = object::File::parse(&*stale).expect("planted artifact is a valid ELF");
    assert!(
        obj.section_by_name(".synth.wasm_mem_1").is_some(),
        "the planted artifact must carry the asserted section — substantial \
         enough to fool an unguarded structure gate"
    );

    // 3. A FAILING compile aimed at that exact path (nonexistent input).
    let missing = std::env::temp_dir()
        .join("synth_mem406_tests")
        .join("does_not_exist_977.wat");
    let _ = std::fs::remove_file(&missing);
    let mut cmd = Command::new(synth());
    cmd.args([
        "compile",
        missing.to_str().unwrap(),
        "--all-exports",
        "-o",
        planted_at.to_str().unwrap(),
        "--relocatable",
        "--target",
        "cortex-m3",
    ]);
    let err = artifact_guard::compile_artifact(&mut cmd, &planted_at)
        .expect_err("REFUSAL REQUIRED: a failed compile must not return last run's bytes");

    // 4. The refusal names the compile, not the parser — and nothing is left.
    assert!(
        err.contains("synth compile FAILED"),
        "the refusal must name the compile failure, got: {err}"
    );
    assert!(
        !err.contains("file magic"),
        "and must not surface as a parse error, got: {err}"
    );
    assert!(
        !planted_at.exists(),
        "the stale artifact must be GONE — left in place, a later reader picks it up"
    );
}