1use 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}