1use std::fs;
2use std::io;
3use std::path::Path;
4
5const SCAFFOLD_SOROBAN_SDK_VERSION: &str = "28.0.0";
15
16const SCAFFOLD_RUST_VERSION: &str = "1.91";
20
21#[derive(Debug, Clone)]
23pub struct ScaffoldConfig {
24 pub name: String,
26 pub minimal: bool,
28 pub force: bool,
30}
31
32#[derive(Debug)]
34pub struct ScaffoldResult {
35 pub files_created: Vec<String>,
37}
38
39pub fn generate_project(config: &ScaffoldConfig) -> io::Result<ScaffoldResult> {
44 let root = Path::new(&config.name);
45
46 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 let crate_name = package_name.replace('-', "_");
70
71 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 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#[derive(Debug, Clone)]
171pub struct PluginScaffoldConfig {
172 pub name: String,
174 pub force: bool,
176}
177
178pub 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
192const PLUGIN_AUDIT_VERSION: &str = env!("CARGO_PKG_VERSION");
199
200fn plugin_lib_name(name: &str) -> String {
203 name.replace('-', "_")
204}
205
206fn 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
222fn 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 if identifier == "Self" {
237 format!("Plugin{identifier}")
238 } else {
239 identifier
240 }
241}
242
243fn 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
279pub 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 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 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 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 #[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 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 #[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 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 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 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 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 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 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 assert_eq!(plugin_struct_name("self"), "PluginSelf");
1238 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}