Skip to main content

sdkt_core/
scaffold.rs

1use std::fs;
2use std::io;
3use std::path::Path;
4
5/// The exact `soroban-sdk` version `sdkt init` writes into a generated
6/// `Cargo.toml` (#111). Bump only to a release verified to give a project where
7/// `cargo check`, `cargo test` and `sdkt build` all succeed.
8///
9/// `28.0.0` is the first stable release targeting Protocol 28 (spec shaking v2
10/// always on, contract data migration, executable references). Because shaking is
11/// build-system-driven, a generated contract must be compiled with
12/// `stellar contract build` — which is exactly the path `sdkt build` selects for
13/// a `soroban-sdk >= 28` manifest.
14const SCAFFOLD_SOROBAN_SDK_VERSION: &str = "28.0.0";
15
16/// Minimum Rust for the generated project. `soroban-sdk` 28.0.0 declares
17/// `rust-version = "1.91.0"` (crates.io metadata), above this workspace's own
18/// MSRV, so 1.91 remains correct.
19const SCAFFOLD_RUST_VERSION: &str = "1.91";
20
21/// Configuration for project scaffolding.
22#[derive(Debug, Clone)]
23pub struct ScaffoldConfig {
24    /// Project name (also used as directory name).
25    pub name: String,
26    /// If true, generate only Cargo.toml, src/lib.rs, .sdkt.toml.
27    pub minimal: bool,
28    /// If true, overwrite existing directory.
29    pub force: bool,
30}
31
32/// Result of a successful scaffold operation.
33#[derive(Debug)]
34pub struct ScaffoldResult {
35    /// Files that were created.
36    pub files_created: Vec<String>,
37}
38
39/// Generate a new Soroban contract project.
40///
41/// Creates the directory structure and all template files.
42/// Returns an error if the directory exists and `force` is false.
43pub fn generate_project(config: &ScaffoldConfig) -> io::Result<ScaffoldResult> {
44    let root = Path::new(&config.name);
45
46    // Extract just the directory name for the Rust package name
47    let package_name = root
48        .file_name()
49        .and_then(|n| n.to_str())
50        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid project name"))?
51        .to_string();
52
53    if root.exists() && !config.force {
54        return Err(io::Error::new(
55            io::ErrorKind::AlreadyExists,
56            format!(
57                "directory '{}' already exists (use --force to overwrite)",
58                config.name
59            ),
60        ));
61    }
62
63    fs::create_dir_all(root.join("src"))?;
64
65    let mut created = Vec::new();
66
67    // --- Always generated ---
68
69    let crate_name = package_name.replace('-', "_");
70
71    // Pinned exactly (#111): a floating requirement let the resolver pick a
72    // `soroban-env-host` whose open `ed25519-dalek` range breaks `testutils`.
73    // `lib` next to `cdylib` lets `tests/basic.rs` link the crate.
74    let cargo_toml = format!(
75        r#"[package]
76name = "{name}"
77version = "0.1.0"
78edition = "2021"
79rust-version = "{rust}"
80
81[dependencies]
82soroban-sdk = "={sdk}"
83
84[dev-dependencies]
85soroban-sdk = {{ version = "={sdk}", features = ["testutils"] }}
86
87[lib]
88crate-type = ["lib", "cdylib"]
89
90[profile.release]
91opt-level = "z"
92overflow-checks = true
93debug = 0
94strip = "symbols"
95debug-assertions = false
96panic = "abort"
97codegen-units = 1
98lto = true
99"#,
100        name = package_name,
101        rust = SCAFFOLD_RUST_VERSION,
102        sdk = SCAFFOLD_SOROBAN_SDK_VERSION,
103    );
104    write_template(root, "Cargo.toml", &cargo_toml, &mut created)?;
105
106    let lib_rs = r#"#![no_std]
107use soroban_sdk::{contract, contractimpl, Env};
108
109#[contract]
110pub struct Contract;
111
112#[contractimpl]
113impl Contract {
114 /// Returns a greeting number.
115    pub fn hello(_env: Env) -> u32 {
116        42
117    }
118}
119"#;
120    write_template(root, "src/lib.rs", lib_rs, &mut created)?;
121
122    let sdkt_toml = r#"[network]
123rpc_url = "https://soroban-testnet.stellar.org"
124passphrase = "Test SDF Network ; September 2015"
125
126[contracts.main]
127path = "."
128"#;
129    write_template(root, ".sdkt.toml", sdkt_toml, &mut created)?;
130
131    // --- Full mode only ---
132
133    if !config.minimal {
134        let readme = format!(
135            "# {name}\n\nA Soroban smart contract project.\n\n## Build\n\n```\nsdkt build\n```\n\n## Test\n\n```\ncargo test\n```\n\n## Deploy to Testnet\n\nCreate and fund a test identity, then deploy the compiled contract:\n\n```\nsdkt identity generate my-dev\nsdkt network add testnet --rpc-url https://soroban-testnet.stellar.org --passphrase \"Test SDF Network ; September 2015\" --friendbot https://friendbot.stellar.org\nsdkt identity fund my-dev --network-profile testnet\nsdkt deploy --wasm target/wasm32v1-none/release/{crate_name}.wasm --identity my-dev --network-profile testnet\n```\n\n## Invoke\n\nReplace `<CONTRACT_ID>` with the deployed contract address:\n\n```\nsdkt call <CONTRACT_ID> hello --network-profile testnet\nsdkt invoke <CONTRACT_ID> hello --identity my-dev --network-profile testnet\n```\n",
136            name = package_name,
137            crate_name = crate_name,
138        );
139        write_template(root, "README.md", &readme, &mut created)?;
140
141        write_template(root, ".gitignore", "/target\n", &mut created)?;
142
143        fs::create_dir_all(root.join("tests"))?;
144
145        let basic_test = format!(
146            r#"#![cfg(test)]
147
148use {crate_name}::{{Contract, ContractClient}};
149use soroban_sdk::Env;
150
151#[test]
152fn test_hello() {{
153    let env = Env::default();
154    let contract_id = env.register(Contract, ());
155    let client = ContractClient::new(&env, &contract_id);
156    assert_eq!(client.hello(), 42);
157}}
158"#,
159            crate_name = crate_name,
160        );
161        write_template(root, "tests/basic.rs", &basic_test, &mut created)?;
162    }
163
164    Ok(ScaffoldResult {
165        files_created: created,
166    })
167}
168
169/// Configuration for plugin-rule scaffolding.
170#[derive(Debug, Clone)]
171pub struct PluginScaffoldConfig {
172    /// Plugin project name (also used as directory name).
173    pub name: String,
174    /// If true, overwrite existing directory.
175    pub force: bool,
176}
177
178/// The exact set of files `sdkt plugin init` generates. Pinned by a test so the
179/// documented layout cannot silently drift.
180pub const PLUGIN_SCAFFOLD_FILES: &[&str] = &[
181    "Cargo.toml",
182    "src/lib.rs",
183    "src/plugin_abi.rs",
184    "src/plugin_abi_wasm.rs",
185    "plugin/plugin.toml",
186    "plugin-wasm/plugin.toml",
187    "README.md",
188    "tests/rule_test.rs",
189    ".gitignore",
190];
191
192/// The version of `sdkt-audit` a scaffolded plugin depends on.
193///
194/// The scaffolded crate is standalone (outside this workspace), so it cannot
195/// use a `path` dependency; it consumes the published crate instead. This is
196/// forwarded from the workspace version so it tracks release bumps; the crate
197/// must be published before the workspace version moves past it.
198const PLUGIN_AUDIT_VERSION: &str = env!("CARGO_PKG_VERSION");
199
200/// Sanitize a crate name into a valid Rust library identifier
201/// (`-` → `_`). Used for the `[lib] name` and derived identifiers.
202fn plugin_lib_name(name: &str) -> String {
203    name.replace('-', "_")
204}
205
206/// Derive a unique rule id from a project name, e.g. `my-rule` → `MY-RULE-001`
207/// (mirrors the reference rule `EXAMPLE-001`).
208fn plugin_rule_id(name: &str) -> String {
209    let base: String = name
210        .chars()
211        .map(|c| {
212            if c.is_ascii_alphanumeric() {
213                c.to_ascii_uppercase()
214            } else {
215                '-'
216            }
217        })
218        .collect();
219    format!("{}-001", base)
220}
221
222/// Upper-camel identifier for the generated rule struct, e.g.
223/// `my-rule` → `MyRule`.
224fn plugin_struct_name(name: &str) -> String {
225    let identifier: String = name
226        .split(['-', '_'])
227        .filter(|s| !s.is_empty())
228        .map(|seg| {
229            let mut it = seg.chars();
230            let head = it.next().map(|c| c.to_ascii_uppercase()).unwrap_or('X');
231            head.to_string() + &it.collect::<String>()
232        })
233        .collect();
234    // `Self` is a reserved Rust identifier, so a project named `self` must not
235    // emit `pub struct Self;`. Prefix it; all other names stay unchanged.
236    if identifier == "Self" {
237        format!("Plugin{identifier}")
238    } else {
239        identifier
240    }
241}
242
243/// Human-readable plugin name, e.g. `my-rule` → `My Rule`.
244fn plugin_display_name(name: &str) -> String {
245    name.split(['-', '_'])
246        .filter(|s| !s.is_empty())
247        .map(|seg| {
248            let mut it = seg.chars();
249            let head = it.next().map(|c| c.to_ascii_uppercase()).unwrap_or('X');
250            head.to_string() + &it.collect::<String>()
251        })
252        .collect::<Vec<_>>()
253        .join(" ")
254}
255
256fn validate_plugin_name(name: &str) -> io::Result<()> {
257    let valid = !name.is_empty()
258        && name
259            .chars()
260            .next()
261            .map(|c| c.is_ascii_alphabetic())
262            .unwrap_or(false)
263        && name
264            .chars()
265            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
266    if !valid {
267        return Err(io::Error::new(
268            io::ErrorKind::InvalidInput,
269            format!(
270                "invalid plugin rule name '{}': use only letters, digits, '-' and '_', \
271                 starting with a letter",
272                name
273            ),
274        ));
275    }
276    Ok(())
277}
278
279/// Generate a new standalone `sdkt-audit` plugin rule crate.
280///
281/// The scaffold delivers a buildable rule derived from the in-tree reference
282/// implementation (`crates/sdkt-audit-example-rule`): a `Cargo.toml` with the
283/// correct `plugins`/`wasm-plugins` feature wiring, a placeholder `AuditRule`
284/// whose id is derived from the project name, the native C-ABI and WASM ABI
285/// exports, a `plugin/plugin.toml` for the pack/install flow, and a README
286/// documenting build → pack → install → audit.
287///
288/// The crate is intentionally standalone (empty `[workspace]`, like
289/// `crates/sdkt-playground`) so `cargo build --release --features plugins`
290/// works out of the box wherever it is placed.
291pub fn generate_plugin_project(config: &PluginScaffoldConfig) -> io::Result<ScaffoldResult> {
292    let root = Path::new(&config.name);
293
294    let package_name = root
295        .file_name()
296        .and_then(|n| n.to_str())
297        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid project name"))?
298        .to_string();
299    validate_plugin_name(&package_name)?;
300
301    if root.exists() && !config.force {
302        return Err(io::Error::new(
303            io::ErrorKind::AlreadyExists,
304            format!(
305                "directory '{}' already exists (use --force to overwrite)",
306                config.name
307            ),
308        ));
309    }
310
311    let lib_name = plugin_lib_name(&package_name);
312    let struct_name = plugin_struct_name(&package_name);
313    let display_name = plugin_display_name(&package_name);
314    let rule_id = plugin_rule_id(&package_name);
315    let trigger = format!("sdkt_{lib_name}_trigger");
316    let description = format!(
317        "{rule_id}: TODO - replace the placeholder logic. Placeholder fires on functions named `{trigger}`."
318    );
319    let audit_version = PLUGIN_AUDIT_VERSION;
320
321    fs::create_dir_all(root.join("src"))?;
322    fs::create_dir_all(root.join("plugin"))?;
323    fs::create_dir_all(root.join("plugin-wasm"))?;
324    fs::create_dir_all(root.join("tests"))?;
325
326    let mut created = Vec::new();
327
328    let cargo_toml = format!(
329        r#"[package]
330name = "{package_name}"
331version = "0.1.0"
332edition = "2021"
333description = "{description}"
334publish = false
335
336[dependencies]
337extism-pdk = {{ version = "1.4.1", optional = true }}
338serde = {{ version = "1", optional = true, features = ["derive"] }}
339serde_json = {{ version = "1", optional = true }}
340
341[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
342sdkt-audit = {{ version = "{audit_version}" }}
343
344[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
345sdkt-audit = {{ version = "{audit_version}" }}
346
347[features]
348# Native shared-library plugin (Phase B): builds a loadable .so/.dylib/.dll.
349plugins = ["sdkt-audit/plugins"]
350
351# WebAssembly plugin (Phase C): builds a sandboxed .wasm module.
352wasm-plugins = ["dep:extism-pdk", "dep:serde", "dep:serde_json", "sdkt-audit/wasm-plugins"]
353
354[lib]
355name = "{lib_name}"
356# rlib for the compiled-in register() path, cdylib for the loadable artifact.
357crate-type = ["rlib", "cdylib"]
358
359# Standalone crate: opts out of any enclosing workspace so `cargo build` works
360# from anywhere the project is placed (mirrors crates/sdkt-playground).
361[workspace]
362"#,
363        package_name = package_name,
364        description = description,
365        lib_name = lib_name,
366        audit_version = audit_version,
367    );
368    write_template(root, "Cargo.toml", &cargo_toml, &mut created)?;
369
370    let lib_rs = format!(
371        r#"//! {struct_name} — an `sdkt-audit` plugin rule scaffolded by `sdkt plugin init`.
372//!
373//! Demonstrates the plugin author workflow from `docs/plugins/plugin-authoring.md`:
374//! 1. Implement the [`sdkt_audit::AuditRule`] trait.
375//! 2. Register the rule via [`register`] (or the `sdkt_audit::register_rule!` macro).
376//! 3. Emit a [`sdkt_audit::Finding`] when the rule's condition holds.
377//!
378//! Build the loadable native artifact with:
379//!
380//! ```bash
381//! cargo build --release --features plugins
382//! ```
383
384#[cfg(not(target_arch = "wasm32"))]
385use sdkt_audit::{{
386    register_rule, AuditContext, AuditReport, AuditRule, BoxedRule, Finding, FnScan, Severity,
387}};
388
389/// {rule_id} — placeholder rule.
390///
391/// TODO: replace the scaffolded detection below with your real rule once the
392/// crate builds and loads.
393#[cfg(not(target_arch = "wasm32"))]
394pub struct {struct_name};
395
396#[cfg(not(target_arch = "wasm32"))]
397impl AuditRule for {struct_name} {{
398    fn id(&self) -> &'static str {{
399        "{rule_id}"
400    }}
401    fn severity(&self) -> Severity {{
402        Severity::Info
403    }}
404    fn description(&self) -> &'static str {{
405        "{description}"
406    }}
407
408    fn check(&self, scans: &[FnScan], _ctx: &AuditContext, report: &mut AuditReport) {{
409        // TODO: implement your real detection logic here. The placeholder below
410        // deliberately fires on any function whose name contains "{trigger}" so
411        // the C-ABI / WASM wiring can be validated end-to-end before you replace
412        // it with a real check.
413        for s in scans {{
414            if s.fn_name.contains("{trigger}") {{
415                report.add(Finding {{
416                    file: None,
417                    rule_id: self.id().to_string(),
418                    severity: self.severity(),
419                    message: format!("{{}} matched trigger function `{{}}`", "{rule_id}", s.fn_name),
420                    location: Some(s.fn_name.clone()),
421                }});
422            }}
423        }}
424    }}
425}}
426
427/// Register this plugin's rule into the process-wide registry (Phase A).
428#[cfg(not(target_arch = "wasm32"))]
429pub fn register() {{
430    register_rule(Box::new({struct_name}) as BoxedRule);
431}}
432
433/// C-ABI exports for native dynamic loading (Phase B).
434#[cfg(all(feature = "plugins", not(target_arch = "wasm32")))]
435mod plugin_abi;
436
437/// JSON-ABI exports for sandboxed WASM loading (Phase C).
438#[cfg(all(feature = "wasm-plugins", target_arch = "wasm32"))]
439mod plugin_abi_wasm;
440"#,
441        struct_name = struct_name,
442        rule_id = rule_id,
443        description = description,
444        trigger = trigger,
445    );
446    write_template(root, "src/lib.rs", &lib_rs, &mut created)?;
447
448    let plugin_abi_rs = format!(
449        r#"//! C-ABI exports for the {struct_name} plugin (Phase B dynamic loading).
450//!
451//! Compiled only with the `plugins` feature, turning this crate into a loadable
452//! shared library (.so/.dylib/.dll). Reuses the in-crate rule logic through the
453//! public `sdkt_audit` API — no rule duplication.
454
455use std::ffi::{{CStr, CString}};
456use std::os::raw::c_char;
457use std::sync::Mutex;
458
459use sdkt_audit::plugin_abi::{{
460    abi_version_pack, SdktAuditFindingC, SdktAuditReportC, MAX_FINDINGS, SEVERITY_INFO,
461}};
462use sdkt_audit::{{AuditReport, AuditRule}};
463
464use crate::{struct_name};
465
466/// Source to analyze, refreshed on every [`sdkt_plugin_init`] call.
467///
468/// `Mutex<Option<String>>` instead of `OnceLock` so the plugin always operates
469/// on the source supplied for the *current* audit execution, not a stale value
470/// from a previous run.
471static SOURCE: Mutex<Option<String>> = Mutex::new(None);
472
473/// Plugin ABI version (must match the host's `SDKT_AUDIT_ABI_MAJOR`).
474///
475/// # Safety
476/// Standard C-ABI export; returns a packed `u32`. No pointers touched.
477#[no_mangle]
478pub unsafe extern "C" fn sdkt_plugin_abi_version() -> u32 {{
479    abi_version_pack()
480}}
481
482/// Rule id `{rule_id}`.
483///
484/// # Safety
485/// Returns a static C string with static lifetime — valid for the program.
486#[no_mangle]
487pub unsafe extern "C" fn sdkt_plugin_id() -> *const c_char {{
488    static ID: &[u8] = b"{rule_id}\0";
489    ID.as_ptr() as *const c_char
490}}
491
492/// Severity: info.
493///
494/// # Safety
495/// Returns a constant `u32`.
496#[no_mangle]
497pub unsafe extern "C" fn sdkt_plugin_severity() -> u32 {{
498    SEVERITY_INFO
499}}
500
501/// Human-readable description.
502///
503/// # Safety
504/// Returns a static C string with static lifetime.
505#[no_mangle]
506pub unsafe extern "C" fn sdkt_plugin_description() -> *const c_char {{
507    static DESC: &[u8] = b"{description}\0";
508    DESC.as_ptr() as *const c_char
509}}
510
511/// Cache the contract source to be analyzed.
512///
513/// # Safety
514/// `src` must be a valid NUL-terminated C string for the duration of the call.
515#[no_mangle]
516pub unsafe extern "C" fn sdkt_plugin_init(src: *const c_char) -> i32 {{
517    if src.is_null() {{
518        return 1;
519    }}
520    match unsafe {{ CStr::from_ptr(src) }}.to_str() {{
521        Ok(s) => {{
522            if let Ok(mut lock) = SOURCE.lock() {{
523                *lock = Some(s.to_string());
524                0
525            }} else {{
526                1
527            }}
528        }}
529        Err(_) => 1,
530    }}
531}}
532
533/// Run this plugin's rule over the cached source and write findings into `report`.
534///
535/// # Safety
536/// `report` must point to a valid, mutable `SdktAuditReportC`.
537#[no_mangle]
538pub unsafe extern "C" fn sdkt_plugin_check(report: *mut SdktAuditReportC) -> i32 {{
539    let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {{
540        if report.is_null() {{
541            return 1;
542        }}
543
544        let src = {{
545            let lock = match SOURCE.lock() {{
546                Ok(l) => l,
547                Err(_) => return 2,
548            }};
549            match &*lock {{
550                Some(s) => s.clone(),
551                None => return 2,
552            }}
553        }};
554
555        // Run ONLY this plugin's own rule (via the in-crate `{struct_name}`),
556        // never the global registry, to avoid re-entrant recursion when the host
557        // invokes this symbol during an `audit_source_with` run.
558        let scans = match sdkt_audit::scan_all_functions_str(&src) {{
559            Some(s) => s,
560            None => return 3,
561        }};
562        let ctx = sdkt_audit::AuditContext {{ spec: None }};
563        let mut local = AuditReport::default();
564        {struct_name}.check(&scans, &ctx, &mut local);
565
566        let out = unsafe {{ &mut *report }};
567        out.count = 0;
568        for f in local.findings {{
569            if out.count >= MAX_FINDINGS {{
570                break;
571            }}
572            let rule_id = match CString::new(f.rule_id) {{
573                Ok(c) => c,
574                Err(_) => continue,
575            }};
576            let message = match CString::new(f.message) {{
577                Ok(c) => c,
578                Err(_) => continue,
579            }};
580            let location = f
581                .location
582                .as_ref()
583                .and_then(|l| CString::new(l.clone()).ok());
584            let slot: &mut SdktAuditFindingC = &mut out.findings[out.count];
585            slot.rule_id = rule_id.into_raw() as *const c_char;
586            slot.severity = match f.severity {{
587                sdkt_audit::Severity::Critical => 0,
588                sdkt_audit::Severity::Info => 2,
589                sdkt_audit::Severity::Warning => 1,
590            }};
591            slot.message = message.into_raw() as *const c_char;
592            slot.location = location
593                .map(|c| c.into_raw() as *const c_char)
594                .unwrap_or(std::ptr::null());
595            out.count += 1;
596        }}
597        0
598    }}));
599
600    res.unwrap_or(4)
601}}
602
603/// Optional cleanup (no-op: `SOURCE` is dropped at process exit).
604///
605/// # Safety
606/// Standard C-ABI export.
607#[no_mangle]
608pub unsafe extern "C" fn sdkt_plugin_free() {{}}
609"#,
610        struct_name = struct_name,
611        rule_id = rule_id,
612        description = description,
613    );
614    write_template(root, "src/plugin_abi.rs", &plugin_abi_rs, &mut created)?;
615
616    let plugin_abi_wasm_rs = format!(
617        r#"//! Extism PDK exports for the WASM plugin architecture (Phase C).
618//!
619//! When built for wasm32, this module exports the required ABI functions so the
620//! `sdkt-audit` host can load it.
621//!
622//! Deliberately self-contained: it does NOT import from `sdkt-audit` to keep
623//! the dependency graph free of host-side crates (syn, sdkt-wasm, etc.) that do
624//! not compile cleanly for wasm32-unknown-unknown.
625
626use extism_pdk::*;
627use serde::{{Deserialize, Serialize}};
628
629// Mirror of host-side ABI constants (kept local to avoid importing sdkt-audit).
630const SDKT_AUDIT_WASM_ABI_MAJOR: i64 = 1;
631const SEVERITY_INFO: u32 = 2;
632
633// ── Wire types (must match host's WasmCheckInput / WasmFinding) ────────────
634
635/// Minimal projection of `FnScan` — only what the rule needs.
636#[derive(Deserialize)]
637pub struct FnScanInput {{
638    pub fn_name: String,
639}}
640
641#[derive(Deserialize)]
642pub struct WasmCheckInput {{
643    pub scans: Vec<FnScanInput>,
644}}
645
646#[derive(Serialize)]
647pub struct WasmFinding {{
648    pub rule_id: String,
649    pub severity: u32,
650    pub message: String,
651    pub location: Option<String>,
652}}
653
654// ── ABI exports ─────────────────────────────────────────────────────────────
655
656#[plugin_fn]
657pub fn sdkt_plugin_abi_version() -> FnResult<i64> {{
658    Ok(SDKT_AUDIT_WASM_ABI_MAJOR)
659}}
660
661#[plugin_fn]
662pub fn sdkt_plugin_id() -> FnResult<String> {{
663    Ok("{rule_id}".to_string())
664}}
665
666#[plugin_fn]
667pub fn sdkt_plugin_severity() -> FnResult<i64> {{
668    Ok(SEVERITY_INFO as i64)
669}}
670
671#[plugin_fn]
672pub fn sdkt_plugin_description() -> FnResult<String> {{
673    Ok("{description}".into())
674}}
675
676#[plugin_fn]
677pub fn sdkt_plugin_check(input_json: String) -> FnResult<String> {{
678    let input: WasmCheckInput = serde_json::from_str(&input_json)?;
679
680    let findings: Vec<WasmFinding> = input
681        .scans
682        .iter()
683        .filter(|s| s.fn_name.contains("{trigger}"))
684        .map(|s| WasmFinding {{
685            rule_id: "{rule_id}".to_string(),
686            severity: SEVERITY_INFO,
687            message: format!("{{}} matched trigger function `{{}}`", "{rule_id}", s.fn_name),
688            location: Some(s.fn_name.clone()),
689        }})
690        .collect();
691
692    Ok(serde_json::to_string(&findings)?)
693}}
694"#,
695        rule_id = rule_id,
696        description = description,
697        trigger = trigger,
698    );
699    write_template(
700        root,
701        "src/plugin_abi_wasm.rs",
702        &plugin_abi_wasm_rs,
703        &mut created,
704    )?;
705
706    // Name the artifact for the host platform (libmy_rule.so on Linux,
707    // libmy_rule.dylib on macOS, my_rule.dll on Windows) so the generated
708    // plugin.toml and README point at the file `cargo build` actually produces.
709    let artifact = format!(
710        "{}{lib_name}{}",
711        std::env::consts::DLL_PREFIX,
712        std::env::consts::DLL_SUFFIX
713    );
714    let plugin_toml = format!(
715        r#"id = "{lib_name}"
716name = "{display_name}"
717version = "0.1.0"
718author = "your-name"
719description = "{description}"
720kind = "native"
721artifact = "{artifact}"
722abi_major = 1
723abi_minor = 0
724"#,
725        lib_name = lib_name,
726        display_name = display_name,
727        description = description,
728        artifact = artifact,
729    );
730    write_template(root, "plugin/plugin.toml", &plugin_toml, &mut created)?;
731
732    // The same rule also targets WASM (Phase C): `--features wasm-plugins`
733    // produces a sandboxed .wasm module, so stage a matching manifest with the
734    // correct kind/extension. Otherwise pack/install of the .wasm artifact
735    // would fail the store's kind-vs-extension validation.
736    let wasm_plugin_toml = format!(
737        r#"id = "{lib_name}"
738name = "{display_name}"
739version = "0.1.0"
740author = "your-name"
741description = "{description}"
742kind = "wasm"
743artifact = "{lib_name}.wasm"
744abi_major = 1
745abi_minor = 0
746"#,
747        lib_name = lib_name,
748        display_name = display_name,
749        description = description,
750    );
751    write_template(
752        root,
753        "plugin-wasm/plugin.toml",
754        &wasm_plugin_toml,
755        &mut created,
756    )?;
757
758    let readme = format!(
759        r#"# {display_name}
760
761A scaffolded [`sdkt-audit`](https://crates.io/crates/sdkt-audit) plugin rule
762(rule id `{rule_id}`, plugin id `{lib_name}`), generated by `sdkt plugin init`.
763
764This crate builds as:
765
766- an **rlib** by default, for the compiled-in `register()` path (Phase A), and
767- a **cdylib** shared library with `--features plugins` (native, Phase B), or
768- a **wasm32** module with `--features wasm-plugins` (WASM, Phase C).
769
770## Layout
771
772- `src/lib.rs` — the [`AuditRule`] implementation with a TODO-marked `check()`.
773- `src/plugin_abi.rs` — native C-ABI exports (`plugins` feature).
774- `src/plugin_abi_wasm.rs` — WASM JSON-ABI exports (`wasm-plugins` feature).
775- `plugin/plugin.toml` — metadata for the pack/install flow (native).
776- `plugin-wasm/plugin.toml` — metadata for the pack/install flow (WASM).
777
778## Workflow
779
780### 1. Build the rule
781
782```bash
783cargo build --release --features plugins
784```
785
786Produces `target/release/{artifact}`.
787
788### 2. Stage the artifact next to `plugin.toml`
789
790```bash
791mkdir -p plugin
792cp target/release/{artifact} plugin/
793```
794
795### 3. Pack a portable bundle (optional)
796
797```bash
798sdkt plugin pack plugin/ --output {lib_name}-0.1.0.sdktplugin
799```
800
801### 4. Install into the local store
802
803```bash
804sdkt plugin install plugin/{artifact}
805```
806
807### 5. Run the rule against a contract
808
809```bash
810sdkt audit path/to/contract.rs --rules {lib_name}
811```
812
813`--rules {lib_name}` resolves the plugin id to the installed artifact. You can
814also point it at the artifact directly: `--rules plugin/{artifact}`.
815
816### 6. WASM plugin (Phase C, optional)
817
818The same rule also builds as a sandboxed `.wasm` module. Build it and stage the
819artifact next to the pre-staged WASM manifest (`plugin-wasm/plugin.toml`,
820`kind = "wasm"`):
821
822```bash
823rustup target add wasm32-wasip1
824cargo build --release --target wasm32-wasip1 --features wasm-plugins
825mkdir -p plugin-wasm
826cp target/wasm32-wasip1/release/{lib_name}.wasm plugin-wasm/
827```
828
829Then pack or install exactly as above, passing `plugin-wasm/` instead of
830`plugin/`:
831
832```bash
833sdkt plugin pack plugin-wasm/ --output {lib_name}-wasm-0.1.0.sdktplugin
834sdkt plugin install plugin-wasm/{lib_name}.wasm
835```
836
837## Testing
838
839```bash
840cargo test --features plugins
841```
842
843The scaffold ships `tests/rule_test.rs` which proves the placeholder rule is
844wired: it must produce exactly one finding when a function name contains
845`{trigger}`, and stay silent otherwise. Replace that logic with your real check,
846then update the test.
847
848See `docs/plugins/plugin-authoring.md` in the Soroban DevKit repository for the full
849authoring guide.
850"#,
851        display_name = display_name,
852        rule_id = rule_id,
853        lib_name = lib_name,
854        trigger = trigger,
855        artifact = artifact,
856    );
857    write_template(root, "README.md", &readme, &mut created)?;
858
859    let rule_test = format!(
860        r#"//! Integration test for the scaffolded plugin rule.
861//!
862//! Proves the placeholder rule is wired end-to-end: a trivially matching
863//! function name produces a finding, while a non-matching name stays silent.
864//! Run with `cargo test --features plugins`.
865
866use {lib_name}::{struct_name};
867use sdkt_audit::{{AuditContext, AuditReport, AuditRule, FnScan}};
868
869/// A function whose name contains the trigger word must produce exactly one
870/// finding carrying the rule id.
871#[test]
872fn rule_fires_on_trigger_function() {{
873    let scans = vec![FnScan {{
874        fn_name: format!("{{}}_admin", "{trigger}"),
875        require_auth: 0,
876        invoke_contract: 0,
877        bound: Default::default(),
878        usage: Default::default(),
879        division_before_multiplication: false,
880        external_call_before_state_write: false,
881    }}];
882    let ctx = AuditContext {{ spec: None }};
883    let mut report = AuditReport::default();
884    {struct_name}.check(&scans, &ctx, &mut report);
885    assert_eq!(
886        report.summary.total, 1,
887        "placeholder rule must fire on a matching function"
888    );
889    assert_eq!(report.findings[0].rule_id, "{rule_id}");
890}}
891
892/// A function whose name does **not** contain the trigger word must produce
893/// zero findings.
894#[test]
895fn rule_silent_on_normal_function() {{
896    let scans = vec![FnScan {{
897        fn_name: "balance_of".to_string(),
898        require_auth: 0,
899        invoke_contract: 0,
900        bound: Default::default(),
901        usage: Default::default(),
902        division_before_multiplication: false,
903        external_call_before_state_write: false,
904    }}];
905    let ctx = AuditContext {{ spec: None }};
906    let mut report = AuditReport::default();
907    {struct_name}.check(&scans, &ctx, &mut report);
908    assert!(report.is_clean());
909}}
910"#,
911        lib_name = lib_name,
912        struct_name = struct_name,
913        rule_id = rule_id,
914        trigger = trigger,
915    );
916    write_template(root, "tests/rule_test.rs", &rule_test, &mut created)?;
917
918    write_template(root, ".gitignore", "/target\n/.sdkt\n/plugin/*.so\n/plugin/*.dylib\n/plugin/*.dll\n/plugin/*.wasm\n/plugin-wasm/*.wasm\n*.sdktplugin\n", &mut created)?;
919
920    Ok(ScaffoldResult {
921        files_created: created,
922    })
923}
924
925fn write_template(
926    root: &Path,
927    rel_path: &str,
928    content: &str,
929    created: &mut Vec<String>,
930) -> io::Result<()> {
931    fs::write(root.join(rel_path), content)?;
932    created.push(rel_path.to_string());
933    Ok(())
934}
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939    use crate::config::DevKitConfig;
940    use std::path::PathBuf;
941
942    fn tmp_dir(name: &str) -> PathBuf {
943        let p = std::env::temp_dir().join(format!("sdkt_test_{}", name));
944        let _ = fs::remove_dir_all(&p);
945        p
946    }
947
948    fn cfg(path: &Path, minimal: bool, force: bool) -> ScaffoldConfig {
949        ScaffoldConfig {
950            name: path.to_string_lossy().to_string(),
951            minimal,
952            force,
953        }
954    }
955
956    #[test]
957    fn full_scaffold_creates_all_files() {
958        let p = tmp_dir("full");
959        let res = generate_project(&cfg(&p, false, false)).unwrap();
960        assert!(p.join("Cargo.toml").exists());
961        assert!(p.join("src/lib.rs").exists());
962        assert!(p.join(".sdkt.toml").exists());
963        assert!(p.join("README.md").exists());
964        assert!(p.join(".gitignore").exists());
965        assert!(p.join("tests/basic.rs").exists());
966        assert_eq!(res.files_created.len(), 6);
967        let config = DevKitConfig::from_file(p.join(".sdkt.toml")).unwrap();
968        assert_eq!(
969            config.network.rpc_url,
970            "https://soroban-testnet.stellar.org"
971        );
972        assert_eq!(
973            config.network.passphrase,
974            "Test SDF Network ; September 2015"
975        );
976        let readme = fs::read_to_string(p.join("README.md")).unwrap();
977        assert!(readme.contains("## Deploy to Testnet"));
978        assert!(readme.contains("sdkt identity generate my-dev"));
979        assert!(readme
980            .contains("sdkt network add testnet --rpc-url https://soroban-testnet.stellar.org"));
981        assert!(readme.contains("sdkt invoke <CONTRACT_ID> hello"));
982        assert_eq!(
983            config.contracts.get("main").map(|c| c.path.as_str()),
984            Some(".")
985        );
986        let _ = fs::remove_dir_all(&p);
987    }
988
989    #[test]
990    fn minimal_scaffold_omits_extras() {
991        let p = tmp_dir("minimal");
992        let res = generate_project(&cfg(&p, true, false)).unwrap();
993        assert!(p.join("Cargo.toml").exists());
994        assert!(p.join("src/lib.rs").exists());
995        assert!(p.join(".sdkt.toml").exists());
996        assert!(!p.join("README.md").exists());
997        assert!(!p.join(".gitignore").exists());
998        assert!(!p.join("tests").exists());
999        assert_eq!(res.files_created.len(), 3);
1000        let _ = fs::remove_dir_all(&p);
1001    }
1002
1003    #[test]
1004    fn rejects_existing_dir() {
1005        let p = tmp_dir("exists");
1006        fs::create_dir_all(&p).unwrap();
1007        let err = generate_project(&cfg(&p, false, false)).unwrap_err();
1008        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
1009        let _ = fs::remove_dir_all(&p);
1010    }
1011
1012    #[test]
1013    fn force_overwrites_existing() {
1014        let p = tmp_dir("force");
1015        fs::create_dir_all(&p).unwrap();
1016        fs::write(p.join("user_file.txt"), "keep me").unwrap();
1017        let res = generate_project(&cfg(&p, false, true)).unwrap();
1018        assert!(res.files_created.len() >= 6);
1019        // User file not deleted
1020        assert!(p.join("user_file.txt").exists());
1021        let _ = fs::remove_dir_all(&p);
1022    }
1023
1024    #[test]
1025    fn cargo_toml_has_no_std_profile() {
1026        let p = tmp_dir("profile");
1027        generate_project(&cfg(&p, true, false)).unwrap();
1028        let content = fs::read_to_string(p.join("Cargo.toml")).unwrap();
1029        assert!(content.contains("[profile.release]"));
1030        assert!(content.contains("panic = \"abort\""));
1031        let _ = fs::remove_dir_all(&p);
1032    }
1033
1034    /// Protocol 28: the generated manifest must target `soroban-sdk` 28.x and
1035    /// therefore route through the canonical `stellar contract build` path that
1036    /// `sdkt build` selects for v28 contracts. The README must also point at the
1037    /// real artifact directory (`wasm32v1-none`), not the legacy
1038    /// `wasm32-unknown-unknown`.
1039    #[test]
1040    fn scaffold_targets_protocol_28_and_routes_to_canonical_build() {
1041        let p = tmp_dir("protocol28");
1042        generate_project(&cfg(&p, false, false)).unwrap();
1043        let cargo = fs::read_to_string(p.join("Cargo.toml")).unwrap();
1044
1045        assert!(
1046            cargo.contains(&format!(
1047                "soroban-sdk = \"={SCAFFOLD_SOROBAN_SDK_VERSION}\""
1048            )),
1049            "{cargo}"
1050        );
1051        assert_eq!(
1052            SCAFFOLD_SOROBAN_SDK_VERSION.split('.').next(),
1053            Some("28"),
1054            "generated projects must target Protocol 28"
1055        );
1056
1057        // The generated manifest is what `sdkt build` reads to pick a build
1058        // system: a >= 28 requirement must select the canonical v28 path.
1059        assert!(
1060            crate::build::requires_v28_build_system(&p),
1061            "generated project must require the Protocol 28 build path"
1062        );
1063
1064        let readme = fs::read_to_string(p.join("README.md")).unwrap();
1065        assert!(
1066            readme.contains("target/wasm32v1-none/release/"),
1067            "README must point at the real artifact dir: {readme}"
1068        );
1069        assert!(
1070            !readme.contains("wasm32-unknown-unknown"),
1071            "README must not reference the legacy wasm target: {readme}"
1072        );
1073
1074        let _ = fs::remove_dir_all(&p);
1075    }
1076
1077    /// #111: the generated manifest must pin the verified soroban-sdk exactly
1078    /// (a caret requirement floats onto a `soroban-env-host` whose open
1079    /// `ed25519-dalek` range breaks `cargo test`), and build a `lib` so the
1080    /// generated integration test can link the crate.
1081    #[test]
1082    fn cargo_toml_pins_verified_sdk_and_links_for_tests() {
1083        let p = tmp_dir("sdkpin");
1084        generate_project(&cfg(&p, false, false)).unwrap();
1085        let cargo = fs::read_to_string(p.join("Cargo.toml")).unwrap();
1086        let pinned = format!("soroban-sdk = \"={SCAFFOLD_SOROBAN_SDK_VERSION}\"");
1087        let dev_pinned = format!(
1088            "soroban-sdk = {{ version = \"={SCAFFOLD_SOROBAN_SDK_VERSION}\", features = [\"testutils\"] }}"
1089        );
1090        assert!(cargo.contains(&pinned), "{cargo}");
1091        assert!(cargo.contains(&dev_pinned), "{cargo}");
1092        assert!(
1093            cargo.contains("crate-type = [\"lib\", \"cdylib\"]"),
1094            "{cargo}"
1095        );
1096        let rust = format!("rust-version = \"{SCAFFOLD_RUST_VERSION}\"");
1097        assert!(cargo.contains(&rust), "{cargo}");
1098
1099        let test = fs::read_to_string(p.join("tests/basic.rs")).unwrap();
1100        assert!(test.contains("env.register(Contract, ())"), "{test}");
1101        assert!(
1102            !test.contains("register_contract"),
1103            "deprecated since soroban-sdk 22"
1104        );
1105        let _ = fs::remove_dir_all(&p);
1106    }
1107
1108    #[test]
1109    fn lib_rs_is_no_std() {
1110        let p = tmp_dir("nostd");
1111        generate_project(&cfg(&p, true, false)).unwrap();
1112        let content = fs::read_to_string(p.join("src/lib.rs")).unwrap();
1113        assert!(content.contains("#![no_std]"));
1114        assert!(content.contains("soroban_sdk"));
1115        let _ = fs::remove_dir_all(&p);
1116    }
1117
1118    #[test]
1119    fn hyphenated_name_produces_valid_crate() {
1120        let p = tmp_dir("hyphen-test");
1121        generate_project(&cfg(&p, false, false)).unwrap();
1122        let test_content = fs::read_to_string(p.join("tests/basic.rs")).unwrap();
1123        // Rust crate names use underscores
1124        assert!(test_content.contains("sdkt_test_hyphen_test"));
1125        let _ = fs::remove_dir_all(&p);
1126    }
1127
1128    fn pcfg(path: &Path, force: bool) -> PluginScaffoldConfig {
1129        PluginScaffoldConfig {
1130            name: path.to_string_lossy().to_string(),
1131            force,
1132        }
1133    }
1134
1135    /// Temp dir whose *basename* is exactly `name` (the plugin scaffold derives
1136    /// the package name from the directory name, so a `sdkt_test_` prefix would
1137    /// leak into the generated crate).
1138    fn tmp_plugin(name: &str) -> PathBuf {
1139        let p = std::env::temp_dir().join(name);
1140        let _ = fs::remove_dir_all(&p);
1141        p
1142    }
1143
1144    #[test]
1145    fn plugin_scaffold_creates_documented_layout() {
1146        let p = tmp_dir("plugin-layout");
1147        let res = generate_plugin_project(&pcfg(&p, false)).unwrap();
1148        for f in PLUGIN_SCAFFOLD_FILES {
1149            assert!(p.join(f).exists(), "missing scaffolded file: {f}");
1150        }
1151        assert_eq!(
1152            res.files_created, PLUGIN_SCAFFOLD_FILES,
1153            "scaffolded file set must match the documented layout"
1154        );
1155        let _ = fs::remove_dir_all(&p);
1156    }
1157
1158    #[test]
1159    fn plugin_scaffold_derives_rule_id_from_name() {
1160        let p = tmp_plugin("plugin-rule-id");
1161        generate_plugin_project(&pcfg(&p, false)).unwrap();
1162        let lib = fs::read_to_string(p.join("src/lib.rs")).unwrap();
1163        assert!(
1164            lib.contains("PLUGIN-RULE-ID-001"),
1165            "rule id derived from project name"
1166        );
1167        assert!(lib.contains("pub struct PluginRuleId;"));
1168        let abi = fs::read_to_string(p.join("src/plugin_abi.rs")).unwrap();
1169        assert!(abi.contains("PLUGIN-RULE-ID-001\\0"));
1170        let _ = fs::remove_dir_all(&p);
1171    }
1172
1173    #[test]
1174    fn plugin_scaffold_wires_standalone_cargo_toml() {
1175        let p = tmp_plugin("plugin-cargo");
1176        generate_plugin_project(&pcfg(&p, false)).unwrap();
1177        let cargo = fs::read_to_string(p.join("Cargo.toml")).unwrap();
1178        // Standalone crate: must opt out of any enclosing workspace.
1179        assert!(cargo.contains("[workspace]"));
1180        assert!(cargo.contains("sdkt-audit"));
1181        assert!(cargo.contains("plugins = [\"sdkt-audit/plugins\"]"));
1182        assert!(cargo.contains("crate-type = [\"rlib\", \"cdylib\"]"));
1183        assert!(cargo.contains("name = \"plugin_cargo\""));
1184        let toml = fs::read_to_string(p.join("plugin/plugin.toml")).unwrap();
1185        assert!(toml.contains("id = \"plugin_cargo\""));
1186        let artifact = format!(
1187            "{}{}{}",
1188            std::env::consts::DLL_PREFIX,
1189            "plugin_cargo",
1190            std::env::consts::DLL_SUFFIX
1191        );
1192        assert!(toml.contains(&format!("artifact = \"{artifact}\"")));
1193        // WASM manifest mirrors the native one but with kind/extension matched.
1194        let wasm = fs::read_to_string(p.join("plugin-wasm/plugin.toml")).unwrap();
1195        assert!(wasm.contains("kind = \"wasm\""));
1196        assert!(wasm.contains("artifact = \"plugin_cargo.wasm\""));
1197        assert!(wasm.contains("abi_major = 1"));
1198        let _ = fs::remove_dir_all(&p);
1199    }
1200
1201    #[test]
1202    fn plugin_scaffold_rejects_existing_dir() {
1203        let p = tmp_dir("plugin-exists");
1204        fs::create_dir_all(&p).unwrap();
1205        let err = generate_plugin_project(&pcfg(&p, false)).unwrap_err();
1206        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
1207        let _ = fs::remove_dir_all(&p);
1208    }
1209
1210    #[test]
1211    fn plugin_scaffold_force_overwrites_existing() {
1212        let p = tmp_dir("plugin-force");
1213        fs::create_dir_all(&p).unwrap();
1214        fs::write(p.join("user_file.txt"), "keep me").unwrap();
1215        let res = generate_plugin_project(&pcfg(&p, true)).unwrap();
1216        assert!(p.join("Cargo.toml").exists());
1217        // User file not deleted
1218        assert!(p.join("user_file.txt").exists());
1219        assert_eq!(res.files_created.len(), PLUGIN_SCAFFOLD_FILES.len());
1220        let _ = fs::remove_dir_all(&p);
1221    }
1222
1223    #[test]
1224    fn plugin_scaffold_rejects_invalid_names() {
1225        let p = tmp_plugin("plugin bad name");
1226        let err = generate_plugin_project(&pcfg(&p, false)).unwrap_err();
1227        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1228        // First char must be a letter.
1229        let p2 = tmp_plugin("9bad");
1230        assert!(generate_plugin_project(&pcfg(&p2, false)).is_err());
1231        let _ = fs::remove_dir_all(&p);
1232    }
1233
1234    #[test]
1235    fn plugin_struct_name_avoids_reserved_identifier() {
1236        // `self` must not yield `pub struct Self;` (Self is a Rust keyword).
1237        assert_eq!(plugin_struct_name("self"), "PluginSelf");
1238        // All other names keep their existing derived identifier.
1239        assert_eq!(plugin_struct_name("self-rule"), "SelfRule");
1240        assert_eq!(plugin_struct_name("my-rule"), "MyRule");
1241        assert_eq!(plugin_struct_name("snake_case_rule"), "SnakeCaseRule");
1242    }
1243
1244    #[test]
1245    fn plugin_scaffold_readme_documents_workflow() {
1246        let p = tmp_dir("plugin-readme");
1247        generate_plugin_project(&pcfg(&p, false)).unwrap();
1248        let readme = fs::read_to_string(p.join("README.md")).unwrap();
1249        assert!(readme.contains("cargo build --release --features plugins"));
1250        assert!(readme.contains("sdkt plugin pack"));
1251        assert!(readme.contains("sdkt plugin install"));
1252        assert!(readme.contains("sdkt audit path/to/contract.rs --rules"));
1253        let _ = fs::remove_dir_all(&p);
1254    }
1255}