Skip to main content

document_svg/document/
postman.rs

1//! Bounded Postman Collection v2.x previews.
2//!
3//! Collection files describe API requests and optional scripts. This adapter
4//! renders an inert request inventory: URLs are masked for secret-like query
5//! values, auth credentials and variable values are omitted, and pre-request/
6//! test scripts plus request/response bodies are never executed or displayed.
7
8use serde_json::Value;
9use std::path::Path;
10
11use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
12use crate::document::html::{HtmlBlock, render_blocks_to_pages};
13use crate::error::{Error, Result};
14use crate::table::{TableAlign, TableData};
15
16const MAX_POSTMAN_BYTES: u64 = 64 * 1024 * 1024;
17const MAX_POSTMAN_DEPTH: usize = 100;
18const MAX_POSTMAN_VALUES: usize = 300_000;
19const MAX_POSTMAN_ITEMS: usize = 200_000;
20const MAX_POSTMAN_STRING_BYTES: usize = 2 * 1024 * 1024;
21const MAX_POSTMAN_TEXT_BYTES: usize = 64 * 1024 * 1024;
22
23pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
24    let text = String::from_utf8_lossy(prefix);
25    let trimmed = text.trim_start_matches('\u{feff}').trim_start();
26    trimmed.starts_with('{')
27        && text.contains("\"info\"")
28        && text.contains("\"item\"")
29        && (text.contains("postman") || text.contains("schema.getpostman.com"))
30}
31
32struct PostmanPageSink<'a> {
33    inner: &'a mut dyn PageConsumer,
34    warnings: &'a [String],
35}
36
37impl PageConsumer for PostmanPageSink<'_> {
38    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
39        page.source_format = "postman".into();
40        if page.title.is_empty() {
41            page.title = "Postman collection".into();
42        }
43        page.description =
44            "Postman request metadata is rendered safely; requests and scripts are never executed"
45                .into();
46        for warning in self.warnings {
47            page.warn(warning.clone());
48        }
49        self.inner.consume(page)
50    }
51}
52
53pub(crate) fn convert(
54    path: &Path,
55    options: &ConvertOptions,
56    sink: &mut dyn PageConsumer,
57) -> Result<Vec<String>> {
58    let bytes = read_limited_file(
59        path,
60        options.max_input_bytes.min(MAX_POSTMAN_BYTES),
61        "Postman collection input",
62    )?;
63    let text = String::from_utf8(bytes).map_err(|error| {
64        Error::InvalidInput(format!("Postman collection must be UTF-8 JSON: {error}"))
65    })?;
66    let (table, metadata, warnings) = parse_collection(&text)?;
67    let blocks = vec![
68        HtmlBlock::Heading {
69            level: 1,
70            text: "Postman collection".into(),
71        },
72        HtmlBlock::Paragraph { text: metadata },
73        HtmlBlock::Table(table),
74    ];
75    let mut page_sink = PostmanPageSink {
76        inner: sink,
77        warnings: &warnings,
78    };
79    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
80    Ok(warnings)
81}
82
83fn parse_collection(text: &str) -> Result<(TableData, String, Vec<String>)> {
84    if text.len() as u64 > MAX_POSTMAN_BYTES || text.len() > MAX_POSTMAN_TEXT_BYTES {
85        return Err(Error::LimitExceeded(format!(
86            "Postman collection exceeds {MAX_POSTMAN_BYTES} bytes"
87        )));
88    }
89    preflight_depth(text)?;
90    let value: Value = serde_json::from_str(text).map_err(|error| {
91        Error::InvalidInput(format!("invalid Postman collection JSON: {error}"))
92    })?;
93    let mut count = 0usize;
94    count_values(&value, 0, &mut count)?;
95    let root = value
96        .as_object()
97        .ok_or_else(|| Error::InvalidInput("Postman collection root must be an object".into()))?;
98    let info = root
99        .get("info")
100        .and_then(Value::as_object)
101        .ok_or_else(|| Error::InvalidInput("Postman collection requires an info object".into()))?;
102    let name = info.get("name").and_then(Value::as_str).unwrap_or_default();
103    let schema = info
104        .get("schema")
105        .and_then(Value::as_str)
106        .unwrap_or_default();
107    if schema.is_empty() && !text.to_ascii_lowercase().contains("postman") {
108        return Err(Error::InvalidInput(
109            "JSON document does not have a Postman collection signature".into(),
110        ));
111    }
112    let items = root
113        .get("item")
114        .and_then(Value::as_array)
115        .ok_or_else(|| Error::InvalidInput("Postman collection requires an item array".into()))?;
116    let mut rows = Vec::new();
117    let mut masked = 0usize;
118    let mut scripts = 0usize;
119    let mut bodies = 0usize;
120    walk_items(items, "", &mut rows, &mut masked, &mut scripts, &mut bodies)?;
121    let mut warnings = vec![
122        "Postman URLs and request metadata are displayed inertly; requests, auth credentials, variables, scripts, headers, cookies and bodies are never executed, fetched or displayed".into(),
123    ];
124    if masked > 0 {
125        warnings.push(format!("{masked} Postman URL query value(s) were masked"));
126    }
127    if scripts > 0 {
128        warnings.push(format!(
129            "{scripts} Postman pre-request/test script event(s) were omitted"
130        ));
131    }
132    if bodies > 0 {
133        warnings.push(format!(
134            "{bodies} Postman request/response body section(s) were omitted"
135        ));
136    }
137    let variables = root
138        .get("variable")
139        .and_then(Value::as_array)
140        .map_or(0, Vec::len);
141    let events = root
142        .get("event")
143        .and_then(Value::as_array)
144        .map_or(0, Vec::len);
145    let auth = root
146        .get("auth")
147        .and_then(Value::as_object)
148        .and_then(|v| v.get("type"))
149        .and_then(Value::as_str)
150        .unwrap_or("none");
151    let mut metadata = vec![
152        format!("Requests: {}", rows.len()),
153        format!("Variables: {variables}"),
154        format!("Collection events: {events}"),
155        format!("Collection auth: {auth}"),
156    ];
157    if !name.is_empty() {
158        metadata.insert(0, format!("Name: {name}"));
159    }
160    if !schema.is_empty() {
161        metadata.push(format!("Schema: {}", truncate(schema)));
162    }
163    Ok((
164        TableData {
165            headers: vec![
166                "Folder / request".into(),
167                "Method".into(),
168                "URL".into(),
169                "Status / responses".into(),
170                "Auth / scripts".into(),
171            ],
172            rows,
173            alignments: vec![TableAlign::Left; 5],
174            raw_source: String::new(),
175        },
176        metadata.join("\n"),
177        warnings,
178    ))
179}
180
181fn walk_items(
182    items: &[Value],
183    folder: &str,
184    rows: &mut Vec<Vec<String>>,
185    masked: &mut usize,
186    scripts: &mut usize,
187    bodies: &mut usize,
188) -> Result<()> {
189    for item in items {
190        if rows.len() >= MAX_POSTMAN_ITEMS {
191            return Err(Error::LimitExceeded(format!(
192                "Postman requests exceed {MAX_POSTMAN_ITEMS}"
193            )));
194        }
195        let Some(object) = item.as_object() else {
196            continue;
197        };
198        let item_name = object
199            .get("name")
200            .and_then(Value::as_str)
201            .unwrap_or("(unnamed)");
202        if let Some(children) = object.get("item").and_then(Value::as_array) {
203            let next_folder = if folder.is_empty() {
204                item_name.to_owned()
205            } else {
206                format!("{folder} / {item_name}")
207            };
208            walk_items(children, &next_folder, rows, masked, scripts, bodies)?;
209            continue;
210        }
211        let Some(request) = object.get("request") else {
212            continue;
213        };
214        let request_object = request.as_object();
215        let method = request_object
216            .and_then(|v| v.get("method"))
217            .and_then(Value::as_str)
218            .unwrap_or("GET")
219            .to_ascii_uppercase();
220        let raw_url = request_object
221            .and_then(|v| v.get("url"))
222            .and_then(|v| {
223                v.as_str().map(ToOwned::to_owned).or_else(|| {
224                    v.as_object()
225                        .and_then(|u| u.get("raw"))
226                        .and_then(Value::as_str)
227                        .map(ToOwned::to_owned)
228                })
229            })
230            .unwrap_or_default();
231        let (url, masked_here) = mask_url(&raw_url);
232        *masked += masked_here;
233        let auth_type = request_object
234            .and_then(|v| v.get("auth"))
235            .and_then(Value::as_object)
236            .and_then(|v| v.get("type"))
237            .and_then(Value::as_str)
238            .unwrap_or("inherit");
239        let response_count = object
240            .get("response")
241            .and_then(Value::as_array)
242            .map_or(0, Vec::len);
243        let response_statuses = object
244            .get("response")
245            .and_then(Value::as_array)
246            .map(|values| {
247                values
248                    .iter()
249                    .filter_map(|v| {
250                        v.get("code")
251                            .and_then(Value::as_i64)
252                            .map(|code| code.to_string())
253                    })
254                    .collect::<Vec<_>>()
255                    .join(", ")
256            })
257            .unwrap_or_default();
258        if object.get("event").is_some() {
259            *scripts += object
260                .get("event")
261                .and_then(Value::as_array)
262                .map_or(1, Vec::len);
263        }
264        if request_object.is_some_and(|v| v.contains_key("body")) || response_count > 0 {
265            *bodies += 1;
266        }
267        let status = if response_statuses.is_empty() {
268            format!("{response_count} response(s)")
269        } else {
270            response_statuses
271        };
272        let auth_scripts = format!(
273            "auth: {auth_type}; scripts: {}",
274            object
275                .get("event")
276                .and_then(Value::as_array)
277                .map_or(0, Vec::len)
278        );
279        let label = if folder.is_empty() {
280            item_name.to_owned()
281        } else {
282            format!("{folder} / {item_name}")
283        };
284        rows.push(vec![
285            truncate(&label),
286            method,
287            truncate(&url),
288            truncate(&status),
289            truncate(&auth_scripts),
290        ]);
291    }
292    Ok(())
293}
294
295fn mask_url(url: &str) -> (String, usize) {
296    let Some((prefix, query)) = url.split_once('?') else {
297        return (truncate(url), 0);
298    };
299    let mut count = 0usize;
300    let query = query
301        .split('&')
302        .map(|part| {
303            let Some((key, value)) = part.split_once('=') else {
304                return part.to_owned();
305            };
306            let normalized = key.to_ascii_lowercase().replace(['-', '_'], "");
307            if [
308                "token",
309                "secret",
310                "password",
311                "apikey",
312                "authorization",
313                "cookie",
314                "session",
315                "credential",
316            ]
317            .iter()
318            .any(|needle| normalized.contains(needle))
319            {
320                count += 1;
321                format!("{key}=***")
322            } else {
323                format!("{key}={value}")
324            }
325        })
326        .collect::<Vec<_>>()
327        .join("&");
328    (truncate(&format!("{prefix}?{query}")), count)
329}
330
331fn truncate(value: &str) -> String {
332    if value.len() <= MAX_POSTMAN_STRING_BYTES {
333        return value.to_owned();
334    }
335    let mut end = MAX_POSTMAN_STRING_BYTES;
336    while !value.is_char_boundary(end) {
337        end -= 1;
338    }
339    format!("{}…", &value[..end])
340}
341
342fn count_values(value: &Value, depth: usize, count: &mut usize) -> Result<()> {
343    if depth > MAX_POSTMAN_DEPTH {
344        return Err(Error::LimitExceeded(format!(
345            "Postman nesting exceeds {MAX_POSTMAN_DEPTH} levels"
346        )));
347    }
348    *count = count.saturating_add(1);
349    if *count > MAX_POSTMAN_VALUES {
350        return Err(Error::LimitExceeded(format!(
351            "Postman collection contains more than {MAX_POSTMAN_VALUES} values"
352        )));
353    }
354    match value {
355        Value::Array(values) => {
356            for item in values {
357                count_values(item, depth + 1, count)?;
358            }
359        }
360        Value::Object(map) => {
361            for item in map.values() {
362                count_values(item, depth + 1, count)?;
363            }
364        }
365        Value::String(text) if text.len() > MAX_POSTMAN_STRING_BYTES => {
366            return Err(Error::LimitExceeded(format!(
367                "Postman string exceeds {MAX_POSTMAN_STRING_BYTES} bytes"
368            )));
369        }
370        _ => {}
371    }
372    Ok(())
373}
374
375fn preflight_depth(text: &str) -> Result<()> {
376    let mut depth = 0usize;
377    let mut quoted = false;
378    let mut escaped = false;
379    for byte in text.bytes() {
380        if quoted {
381            if escaped {
382                escaped = false;
383            } else if byte == b'\\' {
384                escaped = true;
385            } else if byte == b'"' {
386                quoted = false;
387            }
388            continue;
389        }
390        match byte {
391            b'"' => quoted = true,
392            b'{' | b'[' => {
393                depth += 1;
394                if depth > MAX_POSTMAN_DEPTH {
395                    return Err(Error::LimitExceeded(format!(
396                        "Postman nesting exceeds {MAX_POSTMAN_DEPTH} levels"
397                    )));
398                }
399            }
400            b'}' | b']' => depth = depth.saturating_sub(1),
401            _ => {}
402        }
403    }
404    Ok(())
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn previews_collection_requests_and_masks_auth_query() {
413        let source = r#"{"info":{"name":"Demo","schema":"https://schema.postman.com/json/collection/v2.1.0/collection.json"},"item":[{"name":"Pets","item":[{"name":"List","request":{"method":"GET","url":"https://api.example.invalid/pets?api_key=secret&limit=2","auth":{"type":"bearer"},"body":{"raw":"secret"}},"response":[{"code":200}]}],"event":[{"listen":"test","script":{"exec":["secret"]}}]}],"variable":[{"key":"token","value":"secret"}]}"#;
414        let (table, metadata, warnings) = parse_collection(source).unwrap();
415        assert!(metadata.contains("Demo"));
416        assert_eq!(table.rows[0][1], "GET");
417        assert!(table.rows[0][2].contains("api_key=***"));
418        assert!(
419            warnings
420                .iter()
421                .any(|warning| warning.contains("never executed"))
422        );
423    }
424
425    #[test]
426    fn rejects_generic_json() {
427        assert!(parse_collection("{\"info\":{},\"item\":[]}").is_err());
428    }
429}