use crate::codes::{CODES, DoctorCodeDef};
use cratefield_client_ts::{GenerateError, GeneratedPackage};
use std::io::Read;
use std::path::Path;
use std::process::ExitCode;
pub const SCHEMA: u32 = 1;
#[derive(Debug)]
pub struct Refusal {
pub code: &'static DoctorCodeDef,
pub message: String,
}
impl Refusal {
pub fn render_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(&BarePayload {
schema: SCHEMA,
ok: false,
failures: vec![FailureRef {
code: self.code.code,
message: &self.message,
}],
})
}
}
#[derive(Debug)]
pub struct Generated {
pub venture: String,
pub package_name: String,
pub composition_hash: String,
pub out: String,
pub files: Vec<String>,
}
impl Generated {
pub fn render_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(&WrittenPayload {
schema: SCHEMA,
ok: true,
failures: Vec::new(),
package: &self.package_name,
venture: &self.venture,
composition_hash: &self.composition_hash,
out: self.out.clone(),
files_written: self.files.len(),
files: &self.files,
})
}
}
#[derive(serde::Serialize)]
struct FailureRef<'a> {
code: &'a str,
message: &'a str,
}
#[derive(serde::Serialize)]
struct BarePayload<'a> {
schema: u32,
ok: bool,
failures: Vec<FailureRef<'a>>,
}
#[derive(serde::Serialize)]
struct WrittenPayload<'a> {
schema: u32,
ok: bool,
failures: Vec<FailureRef<'a>>,
package: &'a str,
venture: &'a str,
composition_hash: &'a str,
out: String,
files_written: usize,
files: &'a [String],
}
pub fn run(source: &str, out: &Path) -> Result<(), String> {
match client_ts(source, out) {
Ok(generated) => {
print_summary(&generated);
Ok(())
}
Err(refusal) => Err(refusal.message),
}
}
#[must_use = "call process::exit with the returned ExitCode"]
pub fn run_json(source: &str, out: &Path) -> ExitCode {
let (payload, exit) = match client_ts(source, out) {
Ok(generated) => (generated.render_json(), ExitCode::SUCCESS),
Err(refusal) => (refusal.render_json(), ExitCode::FAILURE),
};
match payload {
Ok(line) => {
println!("{line}");
exit
}
Err(err) => {
eprintln!("fz: cannot serialize the report: {err}");
ExitCode::FAILURE
}
}
}
pub fn client_ts(source: &str, out: &Path) -> Result<Generated, Refusal> {
let document = read_document(source)?;
from_document(&document, &describe(source), out)
}
pub fn from_document(document: &str, described: &str, out: &Path) -> Result<Generated, Refusal> {
let value: serde_json::Value = serde_json::from_str(document).map_err(|err| Refusal {
code: &CODES.surface_unreadable,
message: format!("{described} is not valid JSON: {err}"),
})?;
let parsed: cratefield_core::SurfaceDocument =
serde_json::from_value(value).map_err(|err| Refusal {
code: &CODES.surface_invalid,
message: format!(
"{described} is not a /__surface document: {err} — point --surface at the \
JSON the venture serves at /__surface"
),
})?;
let package = cratefield_client_ts::generate(&parsed).map_err(|error| classify(&error))?;
write_package(&package, out)
}
fn read_document(source: &str) -> Result<String, Refusal> {
if source == "-" {
let mut buffer = String::new();
std::io::stdin()
.read_to_string(&mut buffer)
.map_err(|err| Refusal {
code: &CODES.surface_unreadable,
message: format!("cannot read the surface document from stdin: {err}"),
})?;
return Ok(buffer);
}
std::fs::read_to_string(source).map_err(|err| Refusal {
code: &CODES.surface_unreadable,
message: format!("cannot read {}: {err}", Path::new(source).display()),
})
}
fn describe(source: &str) -> String {
if source == "-" {
"the document on stdin".to_owned()
} else {
format!("the document at {source}")
}
}
fn classify(error: &GenerateError) -> Refusal {
let code = match error {
GenerateError::UnsupportedSurfaceApi { .. } => &CODES.surface_unsupported_contract,
GenerateError::NoTablesModule { .. } => &CODES.surface_no_tables_module,
GenerateError::UnknownAction { .. } | GenerateError::UnreadableAction { .. } => {
&CODES.surface_action_unreadable
}
GenerateError::UnsupportedTableName { .. } => &CODES.surface_table_name_invalid,
};
Refusal {
code,
message: error.to_string(),
}
}
fn is_safe_relative(path: &str) -> bool {
let path = Path::new(path);
!path.is_absolute()
&& path.components().all(|component| {
!matches!(
component,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
)
})
}
fn write_package(package: &GeneratedPackage, out: &Path) -> Result<Generated, Refusal> {
let mut written = Vec::with_capacity(package.files.len());
for file in &package.files {
if !is_safe_relative(&file.path) {
return Err(Refusal {
code: &CODES.client_write_failed,
message: format!(
"the generated path `{}` is not package-relative, and the generator \
writes only below --out",
file.path
),
});
}
let dest = out.join(&file.path);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|err| Refusal {
code: &CODES.client_write_failed,
message: format!("cannot create {}: {err}", parent.display()),
})?;
}
std::fs::write(&dest, &file.contents).map_err(|err| Refusal {
code: &CODES.client_write_failed,
message: format!("cannot write {}: {err}", dest.display()),
})?;
written.push(file.path.clone());
}
Ok(Generated {
venture: package.venture.clone(),
package_name: package.package_name.clone(),
composition_hash: package.composition_hash.clone(),
out: out.display().to_string(),
files: written,
})
}
fn print_summary(generated: &Generated) {
println!(
"fz client-ts: {} (venture: {})",
generated.package_name, generated.venture
);
println!(" composition: sha256:{}", generated.composition_hash);
println!(
" wrote {} files to {}:",
generated.files.len(),
generated.out
);
for file in &generated.files {
println!(" {file}");
}
}
#[cfg(test)]
mod tests {
use super::is_safe_relative;
#[test]
fn a_generated_path_may_not_escape_out() {
assert!(is_safe_relative("src/index.ts"));
assert!(is_safe_relative("package.json"));
assert!(!is_safe_relative(".."));
assert!(!is_safe_relative("../evil.ts"), "a `..` escapes --out");
assert!(
!is_safe_relative("src/../../evil.ts"),
"a buried `..` escapes --out too"
);
assert!(!is_safe_relative("/etc/cratefield"), "an absolute path");
}
}