Skip to main content

npmgen_core/npm/
mod.rs

1//! Phases 2 and 3: assemble the publish tree and place the binaries.
2//!
3//! The [`Assembler`] builds the whole tree in a sibling staging directory and
4//! swaps it onto `out` only once complete, so a run is all-or-nothing and a
5//! re-run never leaves orphaned files from a previous (differently-targeted)
6//! tree. Each platform's binary is copied out of cargo's target directory;
7//! platforms whose binary is not yet present are reported in one summary.
8
9mod 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
30/// Manifest file name written in every package directory.
31const PACKAGE_JSON: &str = "package.json";
32/// Suffix of the sibling staging directory assembled before the atomic swap.
33const STAGING_SUFFIX: &str = ".npmgen-staging";
34/// Suffix of the sibling directory the previous tree is moved to during a swap.
35const ASIDE_SUFFIX: &str = ".npmgen-old";
36
37/// Monotonic counter making each set-aside directory name unique within the
38/// process, so the swap never renames onto an existing path.
39static SWAP_SEQ: AtomicU64 = AtomicU64::new(0);
40
41/// Builds the publish tree for one or more projects in a sibling staging
42/// directory, then atomically swaps it onto `out`. Add each project, then
43/// `commit`; dropping without committing discards the staging directory, so a
44/// run is all-or-nothing and a re-run never leaves a previous tree behind.
45#[derive(Debug)]
46pub struct Assembler<'a> {
47    out: &'a Path,
48    staging: PathBuf,
49    committed: bool,
50}
51
52impl<'a> Assembler<'a> {
53    /// Prepare a fresh staging directory for `out`.
54    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    /// Write one project's package tree into staging. Returns the `<name>-<key>`
65    /// of every target whose binary was not present to copy.
66    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    /// Atomically replace `out` with the staged tree.
73    ///
74    /// The existing tree (if any) is first renamed aside to a unique sibling,
75    /// then staging is renamed into the now-free path, then the set-aside tree
76    /// is removed best-effort. This avoids the Windows "remove then rename onto
77    /// the same path" race: there, the directory deletion is asynchronous, so
78    /// the destination is briefly still occupied and the rename fails. Renaming
79    /// aside is synchronous and targets a fresh name, so no such window exists.
80    /// If the swap fails after the set-aside, the previous tree is restored, so
81    /// a failed commit is never data loss.
82    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    /// A genuine sibling of `out` (same parent, so the swap is a cheap rename),
178    /// suffixed with the process id so concurrent runs do not collide. Errors
179    /// when `out` has no final component (`.`, `..`, a root), which would make
180    /// the pre-swap reset delete the wrong directory.
181    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    /// A unique sibling of `out` that holds the previous tree during a swap.
202    /// Keyed by process id and a monotonic counter, so it never names a
203    /// directory that already exists. Mirrors [`Self::staging_dir`]; `out` is
204    /// already known to have a final component (validated when staging was set
205    /// up), so a missing one degrades to a bare name rather than erroring.
206    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/// Failures while assembling the tree or placing binaries.
237#[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}