1use crate::codes::{CODES, DoctorCodeDef};
22use cratefield_client_ts::{GenerateError, GeneratedPackage};
23use std::io::Read;
24use std::path::Path;
25use std::process::ExitCode;
26
27pub const SCHEMA: u32 = 1;
31
32#[derive(Debug)]
36pub struct Refusal {
37 pub code: &'static DoctorCodeDef,
39 pub message: String,
41}
42
43impl Refusal {
44 pub fn render_json(&self) -> Result<String, serde_json::Error> {
53 serde_json::to_string(&BarePayload {
54 schema: SCHEMA,
55 ok: false,
56 failures: vec![FailureRef {
57 code: self.code.code,
58 message: &self.message,
59 }],
60 })
61 }
62}
63
64#[derive(Debug)]
67pub struct Generated {
68 pub venture: String,
70 pub package_name: String,
72 pub composition_hash: String,
75 pub out: String,
77 pub files: Vec<String>,
79}
80
81impl Generated {
82 pub fn render_json(&self) -> Result<String, serde_json::Error> {
91 serde_json::to_string(&WrittenPayload {
92 schema: SCHEMA,
93 ok: true,
94 failures: Vec::new(),
95 package: &self.package_name,
96 venture: &self.venture,
97 composition_hash: &self.composition_hash,
98 out: self.out.clone(),
99 files_written: self.files.len(),
100 files: &self.files,
101 })
102 }
103}
104
105#[derive(serde::Serialize)]
108struct FailureRef<'a> {
109 code: &'a str,
110 message: &'a str,
111}
112
113#[derive(serde::Serialize)]
115struct BarePayload<'a> {
116 schema: u32,
117 ok: bool,
118 failures: Vec<FailureRef<'a>>,
119}
120
121#[derive(serde::Serialize)]
123struct WrittenPayload<'a> {
124 schema: u32,
125 ok: bool,
126 failures: Vec<FailureRef<'a>>,
127 package: &'a str,
128 venture: &'a str,
129 composition_hash: &'a str,
130 out: String,
131 files_written: usize,
132 files: &'a [String],
133}
134
135pub fn run(source: &str, out: &Path) -> Result<(), String> {
143 match client_ts(source, out) {
144 Ok(generated) => {
145 print_summary(&generated);
146 Ok(())
147 }
148 Err(refusal) => Err(refusal.message),
149 }
150}
151
152#[must_use = "call process::exit with the returned ExitCode"]
155pub fn run_json(source: &str, out: &Path) -> ExitCode {
156 let (payload, exit) = match client_ts(source, out) {
157 Ok(generated) => (generated.render_json(), ExitCode::SUCCESS),
158 Err(refusal) => (refusal.render_json(), ExitCode::FAILURE),
159 };
160 match payload {
161 Ok(line) => {
162 println!("{line}");
163 exit
164 }
165 Err(err) => {
166 eprintln!("fz: cannot serialize the report: {err}");
167 ExitCode::FAILURE
168 }
169 }
170}
171
172pub fn client_ts(source: &str, out: &Path) -> Result<Generated, Refusal> {
183 let document = read_document(source)?;
184 from_document(&document, &describe(source), out)
185}
186
187pub fn from_document(document: &str, described: &str, out: &Path) -> Result<Generated, Refusal> {
196 let value: serde_json::Value = serde_json::from_str(document).map_err(|err| Refusal {
200 code: &CODES.surface_unreadable,
201 message: format!("{described} is not valid JSON: {err}"),
202 })?;
203 let parsed: cratefield_core::SurfaceDocument =
204 serde_json::from_value(value).map_err(|err| Refusal {
205 code: &CODES.surface_invalid,
206 message: format!(
207 "{described} is not a /__surface document: {err} — point --surface at the \
208 JSON the venture serves at /__surface"
209 ),
210 })?;
211 let package = cratefield_client_ts::generate(&parsed).map_err(|error| classify(&error))?;
212 write_package(&package, out)
213}
214
215fn read_document(source: &str) -> Result<String, Refusal> {
218 if source == "-" {
219 let mut buffer = String::new();
220 std::io::stdin()
221 .read_to_string(&mut buffer)
222 .map_err(|err| Refusal {
223 code: &CODES.surface_unreadable,
224 message: format!("cannot read the surface document from stdin: {err}"),
225 })?;
226 return Ok(buffer);
227 }
228 std::fs::read_to_string(source).map_err(|err| Refusal {
229 code: &CODES.surface_unreadable,
230 message: format!("cannot read {}: {err}", Path::new(source).display()),
231 })
232}
233
234fn describe(source: &str) -> String {
236 if source == "-" {
237 "the document on stdin".to_owned()
238 } else {
239 format!("the document at {source}")
240 }
241}
242
243fn classify(error: &GenerateError) -> Refusal {
247 let code = match error {
248 GenerateError::UnsupportedSurfaceApi { .. } => &CODES.surface_unsupported_contract,
249 GenerateError::NoTablesModule { .. } => &CODES.surface_no_tables_module,
250 GenerateError::UnknownAction { .. } | GenerateError::UnreadableAction { .. } => {
251 &CODES.surface_action_unreadable
252 }
253 GenerateError::UnsupportedTableName { .. } => &CODES.surface_table_name_invalid,
254 };
255 Refusal {
256 code,
257 message: error.to_string(),
258 }
259}
260
261fn is_safe_relative(path: &str) -> bool {
267 let path = Path::new(path);
268 !path.is_absolute()
269 && path.components().all(|component| {
270 !matches!(
271 component,
272 std::path::Component::ParentDir
273 | std::path::Component::RootDir
274 | std::path::Component::Prefix(_)
275 )
276 })
277}
278
279fn write_package(package: &GeneratedPackage, out: &Path) -> Result<Generated, Refusal> {
283 let mut written = Vec::with_capacity(package.files.len());
284 for file in &package.files {
285 if !is_safe_relative(&file.path) {
286 return Err(Refusal {
287 code: &CODES.client_write_failed,
288 message: format!(
289 "the generated path `{}` is not package-relative, and the generator \
290 writes only below --out",
291 file.path
292 ),
293 });
294 }
295 let dest = out.join(&file.path);
296 if let Some(parent) = dest.parent() {
297 std::fs::create_dir_all(parent).map_err(|err| Refusal {
298 code: &CODES.client_write_failed,
299 message: format!("cannot create {}: {err}", parent.display()),
300 })?;
301 }
302 std::fs::write(&dest, &file.contents).map_err(|err| Refusal {
303 code: &CODES.client_write_failed,
304 message: format!("cannot write {}: {err}", dest.display()),
305 })?;
306 written.push(file.path.clone());
307 }
308 Ok(Generated {
309 venture: package.venture.clone(),
310 package_name: package.package_name.clone(),
311 composition_hash: package.composition_hash.clone(),
312 out: out.display().to_string(),
313 files: written,
314 })
315}
316
317fn print_summary(generated: &Generated) {
320 println!(
321 "fz client-ts: {} (venture: {})",
322 generated.package_name, generated.venture
323 );
324 println!(" composition: sha256:{}", generated.composition_hash);
325 println!(
326 " wrote {} files to {}:",
327 generated.files.len(),
328 generated.out
329 );
330 for file in &generated.files {
331 println!(" {file}");
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::is_safe_relative;
338
339 #[test]
342 fn a_generated_path_may_not_escape_out() {
343 assert!(is_safe_relative("src/index.ts"));
344 assert!(is_safe_relative("package.json"));
345 assert!(!is_safe_relative(".."));
346 assert!(!is_safe_relative("../evil.ts"), "a `..` escapes --out");
347 assert!(
348 !is_safe_relative("src/../../evil.ts"),
349 "a buried `..` escapes --out too"
350 );
351 assert!(!is_safe_relative("/etc/cratefield"), "an absolute path");
352 }
353}