synth-cli 0.59.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
//! #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"
        );
    }
}

// ---------------------------------------------------------------------------
// 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. Blocked on self-contained multi-memory (#406 phase 2).
#[test]
fn multi_memory_safety_bounds_mpu_refuses() {
    let out = compile(
        &two_mem_fixture(),
        &[
            "--relocatable",
            "--safety-bounds",
            "mpu",
            "--target",
            "cortex-m3",
        ],
    );
    assert_refused(
        &out,
        &["multi-memory", "#406", "mpu"],
        "per-memory MPU isolation interlock",
    );
}

/// 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"
    );
}