Skip to content

Commit 8a6c883

Browse files
authored
Use serde to build package json and check npm package name. (#2428)
1 parent 57f067f commit 8a6c883

5 files changed

Lines changed: 197 additions & 3 deletions

File tree

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.

cmd/crates/soroban-spec-typescript/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ soroban-spec = { workspace = true }
1515
thiserror = "1.0.32"
1616
serde = "1.0.82"
1717
serde_derive = "1.0.82"
18-
serde_json = "1.0.82"
18+
serde_json = { version = "1.0.82", features = ["preserve_order"] }
1919
sha2 = "0.9.9"
2020
prettyplease = "0.2.4"
2121
include_dir = { version = "0.7.3", features = ["glob"] }

cmd/crates/soroban-spec-typescript/src/boilerplate.rs

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::{
88
};
99
use stellar_xdr::curr::ScSpecEntry;
1010

11-
use super::generate;
11+
use super::{generate, validate_npm_package_name};
1212

1313
static PROJECT_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/project_template");
1414

@@ -52,6 +52,14 @@ impl Project {
5252
network_passphrase: Option<&str>,
5353
spec: &[ScSpecEntry],
5454
) -> std::io::Result<()> {
55+
validate_npm_package_name(contract_name).map_err(|e| {
56+
std::io::Error::new(
57+
std::io::ErrorKind::InvalidInput,
58+
format!(
59+
"output directory name '{contract_name}' is not a valid npm package name: {e}"
60+
),
61+
)
62+
})?;
5563
self.replace_placeholder_patterns(contract_name, contract_id, rpc_url, network_passphrase)?;
5664
self.append_index_ts(spec, contract_id, network_passphrase)
5765
}
@@ -87,7 +95,12 @@ impl Project {
8795
),
8896
];
8997
let root: &Path = self.as_ref();
90-
["package.json", "README.md", "src/index.ts"]
98+
99+
// Handle package.json with proper JSON serialization
100+
replace_package_json(root, contract_name)?;
101+
102+
// Handle non-JSON files with string replacement
103+
["README.md", "src/index.ts"]
91104
.into_iter()
92105
.try_for_each(|file_name| {
93106
let file = &root.join(file_name);
@@ -139,6 +152,38 @@ impl Project {
139152
}
140153
}
141154

155+
fn replace_package_json(root: &Path, contract_name: &str) -> std::io::Result<()> {
156+
let file = root.join("package.json");
157+
let contents = fs::read_to_string(&file)?;
158+
let mut json: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
159+
std::io::Error::new(
160+
std::io::ErrorKind::InvalidData,
161+
format!("failed to parse package.json template: {e}"),
162+
)
163+
})?;
164+
165+
if let Some(obj) = json.as_object_mut() {
166+
obj.insert(
167+
"name".to_string(),
168+
serde_json::Value::String(contract_name.to_string()),
169+
);
170+
} else {
171+
return Err(std::io::Error::new(
172+
std::io::ErrorKind::InvalidData,
173+
"package.json template must be a JSON object",
174+
));
175+
}
176+
177+
let serialized = serde_json::to_string_pretty(&json).map_err(|e| {
178+
std::io::Error::new(
179+
std::io::ErrorKind::InvalidData,
180+
format!("failed to serialize package.json: {e}"),
181+
)
182+
})?;
183+
// Append trailing newline to match standard formatting
184+
fs::write(&file, format!("{serialized}\n"))
185+
}
186+
142187
#[cfg(test)]
143188
mod test {
144189
use temp_dir::TempDir;
@@ -189,6 +234,51 @@ mod test {
189234
println!("Updated Snapshot!");
190235
}
191236

237+
#[test]
238+
fn test_package_json_name_is_set_correctly() {
239+
let temp_dir = TempDir::new().unwrap();
240+
let _project = init(temp_dir.path()).unwrap();
241+
let pkg_json_path = temp_dir.path().join("package.json");
242+
let contents = fs::read_to_string(&pkg_json_path).unwrap();
243+
let json: serde_json::Value = serde_json::from_str(&contents).unwrap();
244+
assert_eq!(json["name"], "test_custom_types");
245+
let obj = json.as_object().unwrap();
246+
let expected_keys = [
247+
"version",
248+
"name",
249+
"type",
250+
"exports",
251+
"typings",
252+
"scripts",
253+
"dependencies",
254+
"devDependencies",
255+
];
256+
for key in expected_keys {
257+
assert!(
258+
obj.contains_key(key),
259+
"missing expected key in package.json: {key}"
260+
);
261+
}
262+
}
263+
264+
#[test]
265+
fn test_init_rejects_invalid_contract_name() {
266+
let temp_dir = TempDir::new().unwrap();
267+
let p: Project = temp_dir.path().to_path_buf().try_into().unwrap();
268+
let spec = soroban_spec::read::from_wasm(EXAMPLE_WASM).unwrap();
269+
let result = p.init(
270+
r#"foo","optionalDependencies":{"evil":"1"},"z":""#,
271+
Some("CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE"),
272+
Some("https://rpc-futurenet.stellar.org:443"),
273+
Some("Test SDF Future Network ; October 2022"),
274+
&spec,
275+
);
276+
assert!(result.is_err());
277+
let err = result.unwrap_err();
278+
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
279+
assert!(err.to_string().contains("not a valid npm package name"));
280+
}
281+
192282
fn assert_dirs_equal<P: AsRef<Path>>(dir1: P, dir2: P) {
193283
let walker1 = WalkDir::new(&dir1);
194284
let walker2 = WalkDir::new(&dir2);

cmd/crates/soroban-spec-typescript/src/lib.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,68 @@ fn sanitize_string(s: &str) -> String {
415415
.replace('\r', "\\r")
416416
}
417417

418+
/// Validate that a string is a valid npm package name.
419+
///
420+
/// Valid names must:
421+
/// - Be non-empty and at most 214 characters
422+
/// - Contain only lowercase alphanumeric characters, hyphens, dots, and underscores
423+
/// - Not start with a dot or underscore
424+
///
425+
/// Scoped names (e.g. `@scope/name`) are also accepted.
426+
pub fn validate_npm_package_name(name: &str) -> Result<(), String> {
427+
if name.is_empty() {
428+
return Err("npm package name must not be empty".to_string());
429+
}
430+
if name.len() > 214 {
431+
return Err(format!(
432+
"npm package name must be at most 214 characters, got {}",
433+
name.len()
434+
));
435+
}
436+
437+
// Handle scoped packages like @scope/name
438+
let name_to_check = if let Some(rest) = name.strip_prefix('@') {
439+
match rest.split_once('/') {
440+
Some((scope, pkg)) => {
441+
if scope.is_empty() || pkg.is_empty() {
442+
return Err(format!(
443+
"scoped npm package name '{name}' must have non-empty scope and package"
444+
));
445+
}
446+
validate_npm_name_segment(scope)?;
447+
pkg
448+
}
449+
None => {
450+
return Err(format!(
451+
"scoped npm package name '{name}' must contain a '/'"
452+
));
453+
}
454+
}
455+
} else {
456+
name
457+
};
458+
459+
validate_npm_name_segment(name_to_check)
460+
}
461+
462+
fn validate_npm_name_segment(segment: &str) -> Result<(), String> {
463+
if segment.starts_with('.') || segment.starts_with('_') {
464+
return Err(format!(
465+
"npm package name segment '{segment}' must not start with '.' or '_'"
466+
));
467+
}
468+
if let Some(c) = segment
469+
.chars()
470+
.find(|c| !matches!(c, 'a'..='z' | '0'..='9' | '-' | '.' | '_'))
471+
{
472+
return Err(format!(
473+
"npm package name segment '{segment}' contains invalid character '{c}'. \
474+
Only lowercase alphanumeric characters, hyphens, dots, and underscores are allowed"
475+
));
476+
}
477+
Ok(())
478+
}
479+
418480
#[cfg(test)]
419481
mod tests {
420482
use super::*;
@@ -664,4 +726,41 @@ mod tests {
664726
assert!(!result.contains(DOC_TEST));
665727
assert!(!result.contains(METHOD_TEST));
666728
}
729+
730+
#[test]
731+
fn test_validate_npm_package_name_valid() {
732+
assert!(validate_npm_package_name("my-contract").is_ok());
733+
assert!(validate_npm_package_name("foo.bar").is_ok());
734+
assert!(validate_npm_package_name("a123").is_ok());
735+
assert!(validate_npm_package_name("test_custom_types").is_ok());
736+
assert!(validate_npm_package_name("@scope/my-pkg").is_ok());
737+
}
738+
739+
#[test]
740+
fn test_validate_npm_package_name_invalid() {
741+
// Empty
742+
assert!(validate_npm_package_name("").is_err());
743+
// Leading dot
744+
assert!(validate_npm_package_name(".hidden").is_err());
745+
// Leading underscore
746+
assert!(validate_npm_package_name("_private").is_err());
747+
// Uppercase
748+
assert!(validate_npm_package_name("MyContract").is_err());
749+
// Special characters
750+
assert!(validate_npm_package_name("foo\"bar").is_err());
751+
assert!(validate_npm_package_name("foo bar").is_err());
752+
assert!(validate_npm_package_name("foo{bar}").is_err());
753+
// JSON injection payload
754+
assert!(
755+
validate_npm_package_name(r#"foo","optionalDependencies":{"evil":"1"},"z":""#).is_err()
756+
);
757+
// Too long (215 chars)
758+
assert!(validate_npm_package_name(&"a".repeat(215)).is_err());
759+
// Exactly 214 is ok
760+
assert!(validate_npm_package_name(&"a".repeat(214)).is_ok());
761+
// Bad scoped names
762+
assert!(validate_npm_package_name("@/pkg").is_err());
763+
assert!(validate_npm_package_name("@scope/").is_err());
764+
assert!(validate_npm_package_name("@scope").is_err());
765+
}
667766
}

cmd/soroban-cli/src/commands/contract/bindings/typescript.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ pub enum Error {
3939
Spec(#[from] spec_tools::Error),
4040
#[error("Failed to get file name from path: {0:?}")]
4141
FailedToGetFileName(PathBuf),
42+
#[error("--output-dir basename '{0}' is not a valid npm package name: {1}. Use only lowercase alphanumeric characters, hyphens, dots, and underscores")]
43+
InvalidContractName(String, String),
4244
#[error(transparent)]
4345
WasmOrContract(#[from] contract_spec::Error),
4446
#[error(transparent)]
@@ -82,6 +84,8 @@ impl Cmd {
8284
let contract_name = &file_name
8385
.to_str()
8486
.ok_or_else(|| Error::NotUtf8(file_name.to_os_string()))?;
87+
soroban_spec_typescript::validate_npm_package_name(contract_name)
88+
.map_err(|reason| Error::InvalidContractName((*contract_name).to_string(), reason))?;
8589
let (resolved_address, network) = match source {
8690
contract_spec::Source::Contract {
8791
resolved_address,

0 commit comments

Comments
 (0)