mod launcher;
mod meta;
mod platform;
mod substitute;
mod writer;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use tracing::warn;
use launcher::LauncherScript;
use meta::MetaPackage;
use platform::PlatformPackage;
use substitute::{ManifestRenderer, RenderedManifest};
use writer::TreeWriter;
use crate::project::Project;
use crate::target::Target;
const PACKAGE_JSON: &str = "package.json";
const STAGING_SUFFIX: &str = ".npmgen-staging";
const ASIDE_SUFFIX: &str = ".npmgen-old";
static SWAP_SEQ: AtomicU64 = AtomicU64::new(0);
#[derive(Debug)]
pub struct Assembler<'a> {
out: &'a Path,
staging: PathBuf,
committed: bool,
}
impl<'a> Assembler<'a> {
pub fn new(out: &'a Path) -> Result<Self, NpmError> {
let staging = Self::staging_dir(out)?;
Self::reset(&staging)?;
Ok(Self {
out,
staging,
committed: false,
})
}
pub fn add(&self, project: &Project, targets: &[Target]) -> Result<Vec<String>, NpmError> {
let variables = project.variables();
self.write_meta(project, targets, &variables)?;
self.write_platforms(project, targets)
}
pub fn commit(mut self) -> Result<(), NpmError> {
let aside = Self::aside_dir(self.out);
let had_previous = match std::fs::rename(self.out, &aside) {
Ok(()) => true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
Err(source) => {
return Err(NpmError::Swap {
from: self.out.to_path_buf(),
to: aside,
source,
});
}
};
if let Err(source) = std::fs::rename(&self.staging, self.out) {
if had_previous {
let _ = std::fs::rename(&aside, self.out);
}
return Err(NpmError::Swap {
from: self.staging.clone(),
to: self.out.to_path_buf(),
source,
});
}
self.committed = true;
if had_previous {
let _ = std::fs::remove_dir_all(&aside);
}
Ok(())
}
fn write_meta(
&self,
project: &Project,
targets: &[Target],
variables: &BTreeMap<String, String>,
) -> Result<(), NpmError> {
let writer = TreeWriter::new(self.staging.join(&project.identity.name));
writer.ensure()?;
writer.write_json(PACKAGE_JSON, &MetaPackage::new(project, targets).to_value())?;
let renderer = ManifestRenderer::new(variables);
for manifest in &project.config.manifests {
TreeWriter::guard(manifest.src())?;
let src = project.workspace_root.join(manifest.src());
TreeWriter::reject_symlink(&src)?;
match renderer.render(&src)? {
RenderedManifest::Json(value) => writer.write_json(manifest.dest(), &value)?,
RenderedManifest::Toml(text) => writer.write_string(manifest.dest(), &text)?,
}
}
if let Some(launcher) = &project.config.launcher {
let dest = launcher.output();
if launcher.is_generated() {
writer.write_string(dest, &LauncherScript::new(launcher.fail_open()).render())?;
} else {
writer.copy_file(&project.workspace_root.join(dest), dest)?;
}
}
for include in &project.config.include {
let from = project.workspace_root.join(include);
if !writer.copy_path(&from, include)? {
warn!(path = %from.display(), "include path not found; skipped");
}
}
Ok(())
}
fn write_platforms(
&self,
project: &Project,
targets: &[Target],
) -> Result<Vec<String>, NpmError> {
let name = &project.identity.name;
let mut missing = Vec::new();
for target in targets {
let writer = TreeWriter::new(self.staging.join(format!("{name}-{}", target.key)));
writer.ensure()?;
writer.write_json(
PACKAGE_JSON,
&PlatformPackage::new(project, target).to_value(),
)?;
let from = target.binary_path(&project.target_directory, &project.bin);
let dest = target.binary_filename(name);
if !writer.copy_path(&from, &dest)? {
missing.push(format!("{name}-{}", target.key));
}
}
Ok(missing)
}
fn staging_dir(out: &Path) -> Result<PathBuf, NpmError> {
let file_name = out.file_name().ok_or_else(|| NpmError::InvalidOut {
path: out.to_path_buf(),
})?;
if out
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err(NpmError::OutEscape {
path: out.to_path_buf(),
});
}
let mut staged = file_name.to_os_string();
staged.push(format!("{STAGING_SUFFIX}{}", std::process::id()));
Ok(match out.parent() {
Some(parent) => parent.join(staged),
None => PathBuf::from(staged),
})
}
fn aside_dir(out: &Path) -> PathBuf {
let seq = SWAP_SEQ.fetch_add(1, Ordering::Relaxed);
let mut name = out.file_name().unwrap_or_default().to_os_string();
name.push(format!("{ASIDE_SUFFIX}{}-{seq}", std::process::id()));
match out.parent() {
Some(parent) => parent.join(name),
None => PathBuf::from(name),
}
}
fn reset(path: &Path) -> Result<(), NpmError> {
match std::fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(NpmError::Remove {
path: path.to_path_buf(),
source,
}),
}
}
}
impl Drop for Assembler<'_> {
fn drop(&mut self) {
if !self.committed {
let _ = std::fs::remove_dir_all(&self.staging);
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum NpmError {
#[error("creating directory {}", path.display())]
CreateDir {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("writing {}", path.display())]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("reading {}", path.display())]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("listing directory {}", path.display())]
ReadDir {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("copying {} to {}", from.display(), to.display())]
Copy {
from: PathBuf,
to: PathBuf,
#[source]
source: std::io::Error,
},
#[error("removing {}", path.display())]
Remove {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("swapping {} onto {}", from.display(), to.display())]
Swap {
from: PathBuf,
to: PathBuf,
#[source]
source: std::io::Error,
},
#[error("payload path {path:?} escapes the package directory")]
PathEscape { path: String },
#[error("output path {} has no final component to write into (e.g. \".\" or a root)", path.display())]
InvalidOut { path: PathBuf },
#[error("output path {} must not contain \"..\"", path.display())]
OutEscape { path: PathBuf },
#[error("refusing to follow symlink {}", path.display())]
Symlink { path: PathBuf },
#[error("manifest {} is {size} bytes, over the {max}-byte limit", path.display())]
ManifestTooLarge { path: PathBuf, size: u64, max: u64 },
#[error("serializing JSON for {}", path.display())]
Serialize {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("parsing JSON manifest {}", path.display())]
ParseJson {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("parsing TOML manifest {}", path.display())]
ParseToml {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("serializing TOML manifest {}", path.display())]
SerializeToml {
path: PathBuf,
#[source]
source: toml::ser::Error,
},
#[error("manifest {} has no supported extension (.json, .toml)", path.display())]
UnsupportedManifestFormat { path: PathBuf },
#[error("unknown variable ${{{name}}} in manifest {}", path.display())]
UnknownVariable { name: String, path: PathBuf },
#[error("unterminated ${{...}} placeholder in manifest {}", path.display())]
UnterminatedPlaceholder { path: PathBuf },
}
#[cfg(test)]
mod tests {
use super::{Assembler, NpmError};
use crate::config::ManifestSpec;
use std::path::{Path, PathBuf};
fn scratch(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("npmgen-assemble-{}-{tag}", std::process::id()))
}
#[test]
fn output_path_with_parent_dir_is_rejected() {
assert!(matches!(
Assembler::new(Path::new("../escape")).unwrap_err(),
NpmError::OutEscape { .. }
));
}
#[test]
fn manifest_source_escaping_the_workspace_is_rejected() {
let mut project = crate::project::sample_project();
project.config.manifests = vec![ManifestSpec::Path("../secret.json".to_owned())];
let out = scratch("manifest-escape");
let assembler = Assembler::new(&out).unwrap();
let error = assembler.add(&project, &[]).unwrap_err();
assert!(matches!(error, NpmError::PathEscape { .. }));
}
}