1use std::path::{Component, Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8use crate::config::RusdoxConfig;
9use crate::renderer::{
10 NativeRenderer, RenderRequest, RenderSource, Renderer, RENDERER_API_VERSION,
11};
12use crate::{atomic_write_file, InputLimits, ValidationIssue};
13
14pub const PROTOCOL_VERSION: u32 = 1;
16
17pub const MAX_PROTOCOL_REQUEST_BYTES: usize = 2 * 1024 * 1024;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ProtocolOperation {
24 Validate,
26 Render,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct ProtocolOutput {
34 #[serde(default = "default_output_directory")]
36 pub directory: PathBuf,
37 pub name: Option<String>,
39 #[serde(default = "default_true")]
41 pub pdf: bool,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct ProtocolRequest {
48 pub protocol_version: u32,
50 pub request_id: String,
52 pub operation: ProtocolOperation,
54 pub source: RenderSource,
56 #[serde(default)]
58 pub config: Option<RusdoxConfig>,
59 #[serde(default)]
61 pub output: Option<ProtocolOutput>,
62}
63
64#[derive(Debug, Clone, Serialize)]
66pub struct ProtocolArtifact {
67 pub kind: String,
69 pub path: String,
71 pub sha256: String,
73 pub bytes: usize,
75}
76
77#[derive(Debug, Clone, Serialize)]
79pub struct ProtocolTimings {
80 pub parse_ms: f64,
82 pub validate_ms: f64,
84 pub compose_ms: f64,
86 pub docx_ms: f64,
88 pub pdf_ms: f64,
90}
91
92#[derive(Debug, Clone, Serialize)]
94pub struct ProtocolError {
95 pub code: String,
97 pub message: String,
99}
100
101#[derive(Debug, Clone, Serialize)]
103pub struct ProtocolResponse {
104 pub protocol_version: u32,
106 pub request_id: String,
108 pub ok: bool,
110 pub diagnostics: Vec<ValidationIssue>,
112 pub artifacts: Vec<ProtocolArtifact>,
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub timings: Option<ProtocolTimings>,
117 #[serde(skip_serializing_if = "Option::is_none")]
118 pub error: Option<ProtocolError>,
120}
121
122pub fn execute_protocol_request(
124 request: ProtocolRequest,
125 output_root: impl AsRef<Path>,
126) -> ProtocolResponse {
127 execute_protocol_request_with_limits(request, output_root, InputLimits::default())
128}
129
130pub fn execute_protocol_request_with_limits(
135 request: ProtocolRequest,
136 output_root: impl AsRef<Path>,
137 limits: InputLimits,
138) -> ProtocolResponse {
139 let request_id = request.request_id.clone();
140 if let Err(message) = validate_request_envelope(&request) {
141 return protocol_failure(request_id, "invalid_request", message);
142 }
143 if let Err(error) = limits.validate() {
144 return protocol_failure(request_id, "invalid_limits", error.to_string());
145 }
146 let renderer =
147 NativeRenderer::new(request.config.clone().unwrap_or_default()).with_limits(limits);
148 let render_request = RenderRequest {
149 renderer_api_version: RENDERER_API_VERSION,
150 source: request.source.clone(),
151 emit_pdf: request.output.as_ref().is_none_or(|output| output.pdf),
152 };
153 let validation = match renderer.validate(&render_request) {
154 Ok(validation) => validation,
155 Err(error) => return protocol_failure(request_id, "parse_error", error.to_string()),
156 };
157 let validation_timings = ProtocolTimings {
158 parse_ms: duration_ms(validation.parse_duration),
159 validate_ms: duration_ms(validation.validation_duration),
160 compose_ms: 0.0,
161 docx_ms: 0.0,
162 pdf_ms: 0.0,
163 };
164 if !validation.valid {
165 return ProtocolResponse {
166 protocol_version: PROTOCOL_VERSION,
167 request_id,
168 ok: false,
169 diagnostics: validation.diagnostics,
170 artifacts: Vec::new(),
171 timings: Some(validation_timings),
172 error: Some(ProtocolError {
173 code: "validation_failed".to_string(),
174 message: "document spec contains validation errors".to_string(),
175 }),
176 };
177 }
178 if request.operation == ProtocolOperation::Validate {
179 return ProtocolResponse {
180 protocol_version: PROTOCOL_VERSION,
181 request_id,
182 ok: true,
183 diagnostics: validation.diagnostics,
184 artifacts: Vec::new(),
185 timings: Some(validation_timings),
186 error: None,
187 };
188 }
189
190 let output = request.output.expect("render output checked by envelope");
191 let destination = match safe_output_directory(output_root.as_ref(), &output.directory) {
192 Ok(path) => path,
193 Err(message) => return protocol_failure(request_id, "invalid_output", message),
194 };
195 let name = output
196 .name
197 .unwrap_or_else(|| default_artifact_name(&request.source));
198 if !is_safe_artifact_name(&name) {
199 return protocol_failure(
200 request_id,
201 "invalid_output",
202 "output name must use only ASCII letters, digits, '-' or '_'".to_string(),
203 );
204 }
205 let rendered = match renderer.render(&render_request) {
206 Ok(rendered) => rendered,
207 Err(error) => return protocol_failure(request_id, "render_failed", error.to_string()),
208 };
209 if let Err(error) = std::fs::create_dir_all(&destination) {
210 return protocol_failure(request_id, "write_failed", error.to_string());
211 }
212 let docx_path = destination.join(format!("{name}.docx"));
213 if let Err(error) = atomic_write_file(&docx_path, &rendered.docx) {
214 return protocol_failure(request_id, "write_failed", error.to_string());
215 }
216 let mut artifacts = vec![protocol_artifact("docx", &docx_path, &rendered.docx)];
217 if let Some(pdf) = &rendered.pdf {
218 let pdf_path = destination.join(format!("{name}.pdf"));
219 if let Err(error) = atomic_write_file(&pdf_path, pdf) {
220 return protocol_failure(request_id, "write_failed", error.to_string());
221 }
222 artifacts.push(protocol_artifact("pdf", &pdf_path, pdf));
223 }
224 ProtocolResponse {
225 protocol_version: PROTOCOL_VERSION,
226 request_id,
227 ok: true,
228 diagnostics: rendered.diagnostics,
229 artifacts,
230 timings: Some(ProtocolTimings {
231 parse_ms: duration_ms(rendered.parse_duration),
232 validate_ms: duration_ms(rendered.validation_duration),
233 compose_ms: duration_ms(rendered.compose_duration),
234 docx_ms: duration_ms(rendered.docx_duration),
235 pdf_ms: duration_ms(rendered.pdf_duration),
236 }),
237 error: None,
238 }
239}
240
241pub fn protocol_failure(
243 request_id: impl Into<String>,
244 code: impl Into<String>,
245 message: impl Into<String>,
246) -> ProtocolResponse {
247 ProtocolResponse {
248 protocol_version: PROTOCOL_VERSION,
249 request_id: request_id.into(),
250 ok: false,
251 diagnostics: Vec::new(),
252 artifacts: Vec::new(),
253 timings: None,
254 error: Some(ProtocolError {
255 code: code.into(),
256 message: message.into(),
257 }),
258 }
259}
260
261fn validate_request_envelope(request: &ProtocolRequest) -> std::result::Result<(), String> {
262 if request.protocol_version != PROTOCOL_VERSION {
263 return Err(format!(
264 "unsupported protocol_version {}; expected {PROTOCOL_VERSION}",
265 request.protocol_version
266 ));
267 }
268 if request.request_id.is_empty()
269 || request.request_id.chars().count() > 128
270 || request
271 .request_id
272 .chars()
273 .any(|character| character.is_control())
274 {
275 return Err("request_id must contain 1-128 printable characters".to_string());
276 }
277 if request.operation == ProtocolOperation::Render && request.output.is_none() {
278 return Err("render requests require an output object".to_string());
279 }
280 Ok(())
281}
282
283fn safe_output_directory(root: &Path, relative: &Path) -> std::result::Result<PathBuf, String> {
284 if relative.is_absolute()
285 || relative.components().any(|component| {
286 matches!(
287 component,
288 Component::ParentDir | Component::RootDir | Component::Prefix(_)
289 )
290 })
291 {
292 return Err("output directory must stay relative to the service output root".to_string());
293 }
294 let root = if root.is_absolute() {
295 root.to_path_buf()
296 } else {
297 std::env::current_dir()
298 .map_err(|error| error.to_string())?
299 .join(root)
300 };
301 std::fs::create_dir_all(&root).map_err(|error| error.to_string())?;
302 let root = root.canonicalize().map_err(|error| error.to_string())?;
303 let mut destination = root.clone();
304 for component in relative.components() {
305 if let Component::Normal(segment) = component {
306 destination.push(segment);
307 if std::fs::symlink_metadata(&destination)
308 .is_ok_and(|metadata| metadata.file_type().is_symlink())
309 {
310 return Err("output directory must not traverse a symbolic link".to_string());
311 }
312 if destination.exists() {
313 let canonical = destination
314 .canonicalize()
315 .map_err(|error| error.to_string())?;
316 if !canonical.starts_with(&root) {
317 return Err("output directory escapes the service output root".to_string());
318 }
319 }
320 }
321 }
322 Ok(destination)
323}
324
325fn is_safe_artifact_name(value: &str) -> bool {
326 !value.is_empty()
327 && value.len() <= 128
328 && value
329 .bytes()
330 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
331}
332
333fn default_artifact_name(source: &RenderSource) -> String {
334 match source {
335 RenderSource::Path { path } => path
336 .file_stem()
337 .and_then(|stem| stem.to_str())
338 .filter(|stem| is_safe_artifact_name(stem))
339 .unwrap_or("document")
340 .to_string(),
341 RenderSource::Inline { .. } => "document".to_string(),
342 }
343}
344
345fn protocol_artifact(kind: &str, path: &Path, bytes: &[u8]) -> ProtocolArtifact {
346 ProtocolArtifact {
347 kind: kind.to_string(),
348 path: path.display().to_string(),
349 sha256: format!("{:x}", Sha256::digest(bytes)),
350 bytes: bytes.len(),
351 }
352}
353
354fn duration_ms(duration: std::time::Duration) -> f64 {
355 duration.as_secs_f64() * 1000.0
356}
357
358fn default_output_directory() -> PathBuf {
359 PathBuf::from(".")
360}
361
362const fn default_true() -> bool {
363 true
364}
365
366#[cfg(test)]
367mod tests {
368 use tempfile::tempdir;
369
370 use super::{execute_protocol_request, ProtocolOperation, ProtocolOutput, ProtocolRequest};
371 use crate::renderer::{RenderSource, SpecFormat};
372
373 fn request(operation: ProtocolOperation) -> ProtocolRequest {
374 ProtocolRequest {
375 protocol_version: 1,
376 request_id: "test-1".to_string(),
377 operation,
378 source: RenderSource::Inline {
379 format: SpecFormat::Yaml,
380 content:
381 "version: 1\noutput_name: protocol\nblocks:\n - type: body\n text: Hello\n"
382 .to_string(),
383 },
384 config: None,
385 output: None,
386 }
387 }
388
389 #[test]
390 fn validate_is_side_effect_free() {
391 let root = tempdir().expect("temp dir");
392 let response = execute_protocol_request(request(ProtocolOperation::Validate), root.path());
393 assert!(response.ok);
394 assert!(response.artifacts.is_empty());
395 assert_eq!(
396 std::fs::read_dir(root.path()).expect("read root").count(),
397 0
398 );
399 }
400
401 #[test]
402 fn render_writes_hash_described_artifacts_beneath_root() {
403 let root = tempdir().expect("temp dir");
404 let mut request = request(ProtocolOperation::Render);
405 request.output = Some(ProtocolOutput {
406 directory: "nested".into(),
407 name: Some("hello".to_string()),
408 pdf: true,
409 });
410 let response = execute_protocol_request(request, root.path());
411 assert!(response.ok, "{:?}", response.error);
412 assert_eq!(response.artifacts.len(), 2);
413 assert!(root.path().join("nested/hello.docx").is_file());
414 assert!(root.path().join("nested/hello.pdf").is_file());
415 }
416
417 #[test]
418 fn render_rejects_output_escape() {
419 let root = tempdir().expect("temp dir");
420 let mut request = request(ProtocolOperation::Render);
421 request.output = Some(ProtocolOutput {
422 directory: "../outside".into(),
423 name: Some("hello".to_string()),
424 pdf: false,
425 });
426 let response = execute_protocol_request(request, root.path());
427 assert!(!response.ok);
428 assert_eq!(response.error.expect("error").code, "invalid_output");
429 }
430
431 #[cfg(unix)]
432 #[test]
433 fn render_rejects_symlink_escape_beneath_output_root() {
434 use std::os::unix::fs::symlink;
435
436 let root = tempdir().expect("root");
437 let outside = tempdir().expect("outside");
438 symlink(outside.path(), root.path().join("linked")).expect("symlink");
439 let mut request = request(ProtocolOperation::Render);
440 request.output = Some(ProtocolOutput {
441 directory: "linked/nested".into(),
442 name: Some("hello".to_string()),
443 pdf: false,
444 });
445 let response = execute_protocol_request(request, root.path());
446 assert!(!response.ok);
447 assert_eq!(response.error.expect("error").code, "invalid_output");
448 assert!(!outside.path().join("nested/hello.docx").exists());
449 }
450}