Skip to content

Commit 76e466c

Browse files
committed
Ensure valid wasms when publishing app
Signed-off-by: Brian Hardock <[email protected]>
1 parent 35fd67b commit 76e466c

File tree

5 files changed

+203
-0
lines changed

5 files changed

+203
-0
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/oci/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ tokio = { workspace = true, features = ["fs"] }
3030
tokio-util = { version = "0.7", features = ["compat"] }
3131
tracing = { workspace = true }
3232
walkdir = { workspace = true }
33+
wasmparser = { workspace = true }
3334

3435
[dev-dependencies]
3536
wasm-encoder = { workspace = true }

crates/oci/src/client.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use tokio::fs;
2424
use walkdir::WalkDir;
2525

2626
use crate::auth::AuthConfig;
27+
use crate::validate;
2728

2829
// TODO: the media types for application, data and archive layer are not final
2930
/// Media type for a layer representing a locked Spin application configuration
@@ -149,6 +150,12 @@ impl Client {
149150
)
150151
.await?;
151152

153+
// Ensure that all Spin components specify valid wasm binaries in both the `source`
154+
// field and for each dependency.
155+
for locked_component in &locked.components {
156+
validate::ensure_wasms(locked_component).await?;
157+
}
158+
152159
self.push_locked_core(
153160
locked,
154161
auth,

crates/oci/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod auth;
55
pub mod client;
66
mod loader;
77
pub mod utils;
8+
mod validate;
89

910
pub use client::{Client, ComposeMode};
1011
pub use loader::OciLoader;

crates/oci/src/validate.rs

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
use anyhow::{bail, Context, Result};
2+
use spin_common::{ui::quoted_path, url::parse_file_url};
3+
use spin_locked_app::locked::{LockedComponent, LockedComponentSource};
4+
5+
// Validate that all Spin components specify valid wasm binaries in both the `source`
6+
// field and for each dependency.
7+
pub async fn ensure_wasms(component: &LockedComponent) -> Result<()> {
8+
// Ensure that the component source is a valid wasm binary.
9+
let bytes = read_component_source(&component.source).await?;
10+
if !is_wasm_binary(&bytes) {
11+
bail!(
12+
"Component {} source is not a valid .wasm file",
13+
component.id,
14+
);
15+
}
16+
17+
// Ensure that each dependency is a valid wasm binary.
18+
for (dep_name, dep) in &component.dependencies {
19+
let bytes = read_component_source(&dep.source).await?;
20+
if !is_wasm_binary(&bytes) {
21+
bail!(
22+
"dependency {} for component {} is not a valid .wasm file",
23+
dep_name,
24+
component.id,
25+
);
26+
}
27+
}
28+
Ok(())
29+
}
30+
31+
fn is_wasm_binary(bytes: &[u8]) -> bool {
32+
wasmparser::Parser::is_component(bytes) || wasmparser::Parser::is_core_wasm(bytes)
33+
}
34+
35+
async fn read_component_source(source: &LockedComponentSource) -> Result<Vec<u8>> {
36+
let source = source
37+
.content
38+
.source
39+
.as_ref()
40+
.context("LockedComponentSource missing source field")?;
41+
42+
let path = parse_file_url(source)?;
43+
44+
let bytes: Vec<u8> = tokio::fs::read(&path).await.with_context(|| {
45+
format!(
46+
"failed to read component source from disk at path {}",
47+
quoted_path(&path)
48+
)
49+
})?;
50+
Ok(bytes)
51+
}
52+
53+
#[cfg(test)]
54+
mod test {
55+
use super::*;
56+
use crate::from_json;
57+
use spin_locked_app::locked::LockedComponent;
58+
use tokio::io::AsyncWriteExt;
59+
60+
#[tokio::test]
61+
async fn ensures_valid_wasm_binaries() {
62+
let working_dir = tempfile::tempdir().unwrap();
63+
let _ = tokio::fs::create_dir(working_dir.path()).await;
64+
65+
// valid component source
66+
let mut valid_wasm_component_file =
67+
tokio::fs::File::create(working_dir.path().join("component.wasm"))
68+
.await
69+
.expect("should create component file");
70+
valid_wasm_component_file
71+
.write_all(b"\x00\x61\x73\x6D\x0D\x00\x01\x00")
72+
.await
73+
.expect("should write file contents");
74+
75+
// valid core module source
76+
let mut valid_wasm_core_module_file =
77+
tokio::fs::File::create(working_dir.path().join("module.wasm"))
78+
.await
79+
.expect("should create core module file");
80+
valid_wasm_core_module_file
81+
.write_all(b"\x00\x61\x73\x6D\x01\x00\x00\x00")
82+
.await
83+
.expect("should write file contents");
84+
85+
// invalid wasm binary
86+
let mut invalid_wasm_binary = tokio::fs::File::create(working_dir.path().join("invalid"))
87+
.await
88+
.expect("should create file");
89+
invalid_wasm_binary
90+
.write_all(b"not a wasm file")
91+
.await
92+
.expect("should write file contents");
93+
94+
#[derive(Clone)]
95+
struct TestCase {
96+
name: &'static str,
97+
locked_component: LockedComponent,
98+
valid: bool,
99+
}
100+
101+
let tests: Vec<TestCase> = vec![
102+
TestCase {
103+
name: "Valid Spin component source field with component",
104+
locked_component: from_json!({
105+
"id": "component",
106+
"source": {
107+
"content_type": "application/wasm",
108+
"source": format!("file://{}", working_dir.path().join("component.wasm").to_str().unwrap()),
109+
"digest": "digest",
110+
}
111+
}),
112+
valid: true,
113+
},
114+
TestCase {
115+
name: "Valid Spin component source field with core module",
116+
locked_component: from_json!({
117+
"id": "component",
118+
"source": {
119+
"content_type": "application/wasm",
120+
"source": format!("file://{}", working_dir.path().join("module.wasm").to_str().unwrap()),
121+
"digest": "digest",
122+
}
123+
}),
124+
valid: true,
125+
},
126+
TestCase {
127+
name: "Valid Spin component source field with valid wasm dependency",
128+
locked_component: from_json!({
129+
"id": "component",
130+
"source": {
131+
"content_type": "application/wasm",
132+
"source": format!("file://{}", working_dir.path().join("component.wasm").to_str().unwrap()),
133+
"digest": "digest",
134+
},
135+
"dependencies": {
136+
"test:comp2": {
137+
"source": {
138+
"content_type": "application/wasm",
139+
"source": format!("file://{}", working_dir.path().join("component.wasm").to_str().unwrap()),
140+
"digest": "digest",
141+
},
142+
"export": null,
143+
}
144+
}
145+
}),
146+
valid: true,
147+
},
148+
TestCase {
149+
name: "Invalid Spin component source field with invalid wasm binary",
150+
locked_component: from_json!({
151+
"id": "component",
152+
"source": {
153+
"content_type": "application/wasm",
154+
"source": format!("file://{}", working_dir.path().join("invalid").to_str().unwrap()),
155+
"digest": "digest",
156+
}
157+
}),
158+
valid: false,
159+
},
160+
TestCase {
161+
name: "Valid Spin component source field with invalid wasm dependency",
162+
locked_component: from_json!({
163+
"id": "component",
164+
"source": {
165+
"content_type": "application/wasm",
166+
"source": format!("file://{}", working_dir.path().join("component.wasm").to_str().unwrap()),
167+
"digest": "digest",
168+
},
169+
"dependencies": {
170+
"test:comp2": {
171+
"source": {
172+
"content_type": "application/wasm",
173+
"source": format!("file://{}", working_dir.path().join("invalid").to_str().unwrap()),
174+
"digest": "digest",
175+
},
176+
"export": null,
177+
}
178+
}
179+
}),
180+
valid: false,
181+
},
182+
];
183+
184+
for tc in tests {
185+
let result = ensure_wasms(&tc.locked_component).await;
186+
if tc.valid {
187+
assert!(result.is_ok(), "Test failed: {}", tc.name);
188+
} else {
189+
assert!(result.is_err(), "Test should have failed: {}", tc.name);
190+
}
191+
}
192+
}
193+
}

0 commit comments

Comments
 (0)