Skip to main content

rusdox/
protocol.rs

1//! Versioned local JSON protocol shared by stdin/stdout and loopback HTTP transports.
2
3use 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
14/// Stable protocol version accepted by local integrations.
15pub const PROTOCOL_VERSION: u32 = 1;
16
17/// Maximum UTF-8 JSON request size accepted by bundled transports.
18pub const MAX_PROTOCOL_REQUEST_BYTES: usize = 2 * 1024 * 1024;
19
20/// Supported protocol operations.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ProtocolOperation {
24    /// Parse and validate without producing files.
25    Validate,
26    /// Produce atomic DOCX and optional PDF artifacts.
27    Render,
28}
29
30/// Output selection for a render operation.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct ProtocolOutput {
34    /// Relative directory beneath the service output root.
35    #[serde(default = "default_output_directory")]
36    pub directory: PathBuf,
37    /// Safe artifact stem. Derived from the source when omitted.
38    pub name: Option<String>,
39    /// Generate native PDF alongside DOCX.
40    #[serde(default = "default_true")]
41    pub pdf: bool,
42}
43
44/// One request in the stable local protocol.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct ProtocolRequest {
48    /// Must equal [`PROTOCOL_VERSION`].
49    pub protocol_version: u32,
50    /// Caller-selected correlation identifier, limited to 128 printable characters.
51    pub request_id: String,
52    /// Validation or render operation.
53    pub operation: ProtocolOperation,
54    /// File-backed or inline spec source.
55    pub source: RenderSource,
56    /// Optional complete config object. Defaults are used when absent.
57    #[serde(default)]
58    pub config: Option<RusdoxConfig>,
59    /// Required only for render operations.
60    #[serde(default)]
61    pub output: Option<ProtocolOutput>,
62}
63
64/// One written artifact and its integrity metadata.
65#[derive(Debug, Clone, Serialize)]
66pub struct ProtocolArtifact {
67    /// `docx` or `pdf`.
68    pub kind: String,
69    /// Absolute local output path.
70    pub path: String,
71    /// SHA-256 of the written bytes.
72    pub sha256: String,
73    /// Written byte count.
74    pub bytes: usize,
75}
76
77/// Per-stage timings in milliseconds.
78#[derive(Debug, Clone, Serialize)]
79pub struct ProtocolTimings {
80    /// Parse, in milliseconds.
81    pub parse_ms: f64,
82    /// Validate, in milliseconds.
83    pub validate_ms: f64,
84    /// Compose, in milliseconds.
85    pub compose_ms: f64,
86    /// DOCX, in milliseconds.
87    pub docx_ms: f64,
88    /// PDF, in milliseconds.
89    pub pdf_ms: f64,
90}
91
92/// Machine-readable protocol failure.
93#[derive(Debug, Clone, Serialize)]
94pub struct ProtocolError {
95    /// Stable error category.
96    pub code: String,
97    /// Human-readable local diagnostic.
98    pub message: String,
99}
100
101/// One response emitted for every request, including malformed or rejected work.
102#[derive(Debug, Clone, Serialize)]
103pub struct ProtocolResponse {
104    /// Protocol version.
105    pub protocol_version: u32,
106    /// Request identifier.
107    pub request_id: String,
108    /// Whether the request completed without an error.
109    pub ok: bool,
110    /// Ordered diagnostics.
111    pub diagnostics: Vec<ValidationIssue>,
112    /// Ordered artifacts.
113    pub artifacts: Vec<ProtocolArtifact>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    /// Optional timings.
116    pub timings: Option<ProtocolTimings>,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    /// Optional error.
119    pub error: Option<ProtocolError>,
120}
121
122/// Execute one validated request beneath a fixed local output root.
123pub 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
130/// Executes one request with service-owner resource ceilings.
131///
132/// Limits are supplied out of band so an untrusted request cannot raise its
133/// own ZIP, XML, spec, include, or visual allocation budget.
134pub 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
241/// Build a response for malformed transport input that could not be deserialized.
242pub 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}