1mod launcher;
10mod meta;
11mod platform;
12mod substitute;
13mod writer;
14
15use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17use std::sync::atomic::{AtomicU64, Ordering};
18
19use tracing::warn;
20
21use launcher::LauncherScript;
22use meta::MetaPackage;
23use platform::PlatformPackage;
24use substitute::{ManifestRenderer, RenderedManifest};
25use writer::TreeWriter;
26
27use crate::project::Project;
28use crate::target::Target;
29
30const PACKAGE_JSON: &str = "package.json";
32const STAGING_SUFFIX: &str = ".npmgen-staging";
34const ASIDE_SUFFIX: &str = ".npmgen-old";
36
37static SWAP_SEQ: AtomicU64 = AtomicU64::new(0);
40
41#[derive(Debug)]
46pub struct Assembler<'a> {
47 out: &'a Path,
48 staging: PathBuf,
49 committed: bool,
50}
51
52impl<'a> Assembler<'a> {
53 pub fn new(out: &'a Path) -> Result<Self, NpmError> {
55 let staging = Self::staging_dir(out)?;
56 Self::reset(&staging)?;
57 Ok(Self {
58 out,
59 staging,
60 committed: false,
61 })
62 }
63
64 pub fn add(&self, project: &Project, targets: &[Target]) -> Result<Vec<String>, NpmError> {
67 let variables = project.variables();
68 self.write_meta(project, targets, &variables)?;
69 self.write_platforms(project, targets)
70 }
71
72 pub fn commit(mut self) -> Result<(), NpmError> {
83 let aside = Self::aside_dir(self.out);
84 let had_previous = match std::fs::rename(self.out, &aside) {
85 Ok(()) => true,
86 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
87 Err(source) => {
88 return Err(NpmError::Swap {
89 from: self.out.to_path_buf(),
90 to: aside,
91 source,
92 });
93 }
94 };
95
96 if let Err(source) = std::fs::rename(&self.staging, self.out) {
97 if had_previous {
98 let _ = std::fs::rename(&aside, self.out);
99 }
100 return Err(NpmError::Swap {
101 from: self.staging.clone(),
102 to: self.out.to_path_buf(),
103 source,
104 });
105 }
106
107 self.committed = true;
108 if had_previous {
109 let _ = std::fs::remove_dir_all(&aside);
110 }
111 Ok(())
112 }
113
114 fn write_meta(
115 &self,
116 project: &Project,
117 targets: &[Target],
118 variables: &BTreeMap<String, String>,
119 ) -> Result<(), NpmError> {
120 let writer = TreeWriter::new(self.staging.join(&project.identity.name));
121 writer.ensure()?;
122 writer.write_json(PACKAGE_JSON, &MetaPackage::new(project, targets).to_value())?;
123
124 let renderer = ManifestRenderer::new(variables);
125 for manifest in &project.config.manifests {
126 TreeWriter::guard(manifest.src())?;
127 let src = project.workspace_root.join(manifest.src());
128 TreeWriter::reject_symlink(&src)?;
129 match renderer.render(&src)? {
130 RenderedManifest::Json(value) => writer.write_json(manifest.dest(), &value)?,
131 RenderedManifest::Toml(text) => writer.write_string(manifest.dest(), &text)?,
132 }
133 }
134
135 if let Some(launcher) = &project.config.launcher {
136 let dest = launcher.output();
137 if launcher.is_generated() {
138 writer.write_string(dest, &LauncherScript::new(launcher.fail_open()).render())?;
139 } else {
140 writer.copy_file(&project.workspace_root.join(dest), dest)?;
141 }
142 }
143
144 for include in &project.config.include {
145 let from = project.workspace_root.join(include);
146 if !writer.copy_path(&from, include)? {
147 warn!(path = %from.display(), "include path not found; skipped");
148 }
149 }
150 Ok(())
151 }
152
153 fn write_platforms(
154 &self,
155 project: &Project,
156 targets: &[Target],
157 ) -> Result<Vec<String>, NpmError> {
158 let name = &project.identity.name;
159 let mut missing = Vec::new();
160 for target in targets {
161 let writer = TreeWriter::new(self.staging.join(format!("{name}-{}", target.key)));
162 writer.ensure()?;
163 writer.write_json(
164 PACKAGE_JSON,
165 &PlatformPackage::new(project, target).to_value(),
166 )?;
167
168 let from = target.binary_path(&project.target_directory, &project.bin);
169 let dest = target.binary_filename(name);
170 if !writer.copy_path(&from, &dest)? {
171 missing.push(format!("{name}-{}", target.key));
172 }
173 }
174 Ok(missing)
175 }
176
177 fn staging_dir(out: &Path) -> Result<PathBuf, NpmError> {
182 let file_name = out.file_name().ok_or_else(|| NpmError::InvalidOut {
183 path: out.to_path_buf(),
184 })?;
185 if out
186 .components()
187 .any(|component| matches!(component, std::path::Component::ParentDir))
188 {
189 return Err(NpmError::OutEscape {
190 path: out.to_path_buf(),
191 });
192 }
193 let mut staged = file_name.to_os_string();
194 staged.push(format!("{STAGING_SUFFIX}{}", std::process::id()));
195 Ok(match out.parent() {
196 Some(parent) => parent.join(staged),
197 None => PathBuf::from(staged),
198 })
199 }
200
201 fn aside_dir(out: &Path) -> PathBuf {
207 let seq = SWAP_SEQ.fetch_add(1, Ordering::Relaxed);
208 let mut name = out.file_name().unwrap_or_default().to_os_string();
209 name.push(format!("{ASIDE_SUFFIX}{}-{seq}", std::process::id()));
210 match out.parent() {
211 Some(parent) => parent.join(name),
212 None => PathBuf::from(name),
213 }
214 }
215
216 fn reset(path: &Path) -> Result<(), NpmError> {
217 match std::fs::remove_dir_all(path) {
218 Ok(()) => Ok(()),
219 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
220 Err(source) => Err(NpmError::Remove {
221 path: path.to_path_buf(),
222 source,
223 }),
224 }
225 }
226}
227
228impl Drop for Assembler<'_> {
229 fn drop(&mut self) {
230 if !self.committed {
231 let _ = std::fs::remove_dir_all(&self.staging);
232 }
233 }
234}
235
236#[derive(Debug, thiserror::Error)]
238pub enum NpmError {
239 #[error("creating directory {}", path.display())]
240 CreateDir {
241 path: PathBuf,
242 #[source]
243 source: std::io::Error,
244 },
245
246 #[error("writing {}", path.display())]
247 Write {
248 path: PathBuf,
249 #[source]
250 source: std::io::Error,
251 },
252
253 #[error("reading {}", path.display())]
254 Read {
255 path: PathBuf,
256 #[source]
257 source: std::io::Error,
258 },
259
260 #[error("listing directory {}", path.display())]
261 ReadDir {
262 path: PathBuf,
263 #[source]
264 source: std::io::Error,
265 },
266
267 #[error("copying {} to {}", from.display(), to.display())]
268 Copy {
269 from: PathBuf,
270 to: PathBuf,
271 #[source]
272 source: std::io::Error,
273 },
274
275 #[error("removing {}", path.display())]
276 Remove {
277 path: PathBuf,
278 #[source]
279 source: std::io::Error,
280 },
281
282 #[error("swapping {} onto {}", from.display(), to.display())]
283 Swap {
284 from: PathBuf,
285 to: PathBuf,
286 #[source]
287 source: std::io::Error,
288 },
289
290 #[error("payload path {path:?} escapes the package directory")]
291 PathEscape { path: String },
292
293 #[error("output path {} has no final component to write into (e.g. \".\" or a root)", path.display())]
294 InvalidOut { path: PathBuf },
295
296 #[error("output path {} must not contain \"..\"", path.display())]
297 OutEscape { path: PathBuf },
298
299 #[error("refusing to follow symlink {}", path.display())]
300 Symlink { path: PathBuf },
301
302 #[error("manifest {} is {size} bytes, over the {max}-byte limit", path.display())]
303 ManifestTooLarge { path: PathBuf, size: u64, max: u64 },
304
305 #[error("serializing JSON for {}", path.display())]
306 Serialize {
307 path: PathBuf,
308 #[source]
309 source: serde_json::Error,
310 },
311
312 #[error("parsing JSON manifest {}", path.display())]
313 ParseJson {
314 path: PathBuf,
315 #[source]
316 source: serde_json::Error,
317 },
318
319 #[error("parsing TOML manifest {}", path.display())]
320 ParseToml {
321 path: PathBuf,
322 #[source]
323 source: toml::de::Error,
324 },
325
326 #[error("serializing TOML manifest {}", path.display())]
327 SerializeToml {
328 path: PathBuf,
329 #[source]
330 source: toml::ser::Error,
331 },
332
333 #[error("manifest {} has no supported extension (.json, .toml)", path.display())]
334 UnsupportedManifestFormat { path: PathBuf },
335
336 #[error("unknown variable ${{{name}}} in manifest {}", path.display())]
337 UnknownVariable { name: String, path: PathBuf },
338
339 #[error("unterminated ${{...}} placeholder in manifest {}", path.display())]
340 UnterminatedPlaceholder { path: PathBuf },
341}
342
343#[cfg(test)]
344mod tests {
345 use super::{Assembler, NpmError};
346 use crate::config::ManifestSpec;
347 use std::path::{Path, PathBuf};
348
349 fn scratch(tag: &str) -> PathBuf {
350 std::env::temp_dir().join(format!("npmgen-assemble-{}-{tag}", std::process::id()))
351 }
352
353 #[test]
354 fn output_path_with_parent_dir_is_rejected() {
355 assert!(matches!(
356 Assembler::new(Path::new("../escape")).unwrap_err(),
357 NpmError::OutEscape { .. }
358 ));
359 }
360
361 #[test]
362 fn manifest_source_escaping_the_workspace_is_rejected() {
363 let mut project = crate::project::sample_project();
364 project.config.manifests = vec![ManifestSpec::Path("../secret.json".to_owned())];
365
366 let out = scratch("manifest-escape");
367 let assembler = Assembler::new(&out).unwrap();
368 let error = assembler.add(&project, &[]).unwrap_err();
369 assert!(matches!(error, NpmError::PathEscape { .. }));
370 }
371}