Skip to main content

fallow_output/
json_paths.rs

1//! Shared JSON path post-processing for output contracts.
2
3/// Recursively strip a project-root prefix from all string values in a JSON
4/// tree.
5///
6/// This keeps machine output relative to the analyzed root even when upstream
7/// analysis stages temporarily carry absolute paths.
8pub fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
9    match value {
10        serde_json::Value::String(s) => strip_root_prefix_from_string(s, prefix),
11        serde_json::Value::Array(items) => {
12            for item in items {
13                strip_root_prefix(item, prefix);
14            }
15        }
16        serde_json::Value::Object(map) => {
17            for (key, value) in map.iter_mut() {
18                if key == VERBATIM_KEY {
19                    continue;
20                }
21                strip_root_prefix(value, prefix);
22            }
23        }
24        _ => {}
25    }
26}
27
28/// The one envelope member whose strings are not paths of the analyzed tree.
29///
30/// `request_outcomes` echoes what the user asked for (`requested`) and the
31/// sentence the CLI printed (`message`), and its contract promises both
32/// unchanged. Rewriting them would turn a `--sarif-file` under the root into a
33/// path the consumer cannot open from its own working directory, and would
34/// make the wire sentence differ from the stderr line it mirrors.
35const VERBATIM_KEY: &str = "request_outcomes";
36
37fn strip_root_prefix_from_string(value: &mut String, prefix: &str) {
38    if let Some(rest) = value.strip_prefix(prefix) {
39        *value = rest.to_string();
40        return;
41    }
42
43    let normalized = normalize_output_path(value);
44    let normalized_prefix = normalize_output_path(prefix);
45    if let Some(rest) = normalized.strip_prefix(&normalized_prefix) {
46        *value = rest.to_string();
47    } else if let Some(stripped) = strip_embedded_root_prefixes(&normalized, &normalized_prefix) {
48        *value = stripped;
49    }
50}
51
52fn normalize_output_path(path: &str) -> String {
53    normalize_uri(path)
54}
55
56/// Normalize a path string to a valid URI: forward slashes and percent-encoded
57/// brackets.
58///
59/// Brackets (`[`, `]`) are not valid in URI path segments per RFC 3986 and
60/// cause SARIF / CodeClimate validation warnings for framework routes such as
61/// Next.js dynamic segments.
62#[must_use]
63pub fn normalize_uri(path: &str) -> String {
64    path.replace('\\', "/")
65        .replace('[', "%5B")
66        .replace(']', "%5D")
67}
68
69fn strip_embedded_root_prefixes(value: &str, prefix: &str) -> Option<String> {
70    let mut output = String::with_capacity(value.len());
71    let mut changed = false;
72    let mut last = 0;
73    let mut search_from = 0;
74
75    while let Some(offset) = value[search_from..].find(prefix) {
76        let index = search_from + offset;
77        let can_strip = index > 0
78            && value[..index]
79                .chars()
80                .next_back()
81                .is_some_and(is_embedded_path_boundary);
82
83        if can_strip {
84            output.push_str(&value[last..index]);
85            last = index + prefix.len();
86            changed = true;
87        }
88
89        search_from = index + prefix.len();
90    }
91
92    if changed {
93        output.push_str(&value[last..]);
94        Some(output)
95    } else {
96        None
97    }
98}
99
100fn is_embedded_path_boundary(c: char) -> bool {
101    c.is_whitespace() || matches!(c, '"' | '\'' | '`' | '(' | '[' | '{' | ':' | '=')
102}
103
104#[cfg(test)]
105mod tests {
106    use serde_json::json;
107
108    use super::*;
109
110    #[test]
111    fn strips_root_from_nested_strings() {
112        let mut value = json!({
113            "path": "/project/src/index.ts",
114            "items": ["/project/src/a.ts", { "path": "/project/src/b.ts" }]
115        });
116
117        strip_root_prefix(&mut value, "/project/");
118
119        assert_eq!(value["path"], "src/index.ts");
120        assert_eq!(value["items"][0], "src/a.ts");
121        assert_eq!(value["items"][1]["path"], "src/b.ts");
122    }
123
124    #[test]
125    fn normalizes_windows_separators_before_stripping() {
126        let mut value = json!("C:\\repo\\src\\index.ts");
127
128        strip_root_prefix(&mut value, "C:/repo/");
129
130        assert_eq!(value, json!("src/index.ts"));
131    }
132
133    #[test]
134    fn rewrites_embedded_path_strings() {
135        let mut value = json!("See /project/src/a.ts and /project/src/b.ts");
136
137        strip_root_prefix(&mut value, "/project/");
138
139        assert_eq!(value, json!("See src/a.ts and src/b.ts"));
140    }
141
142    #[test]
143    fn leaves_request_outcomes_verbatim_while_stripping_its_siblings() {
144        let mut value = json!({
145            "path": "/project/src/index.ts",
146            "request_outcomes": {
147                "sarif-file": {
148                    "requested": "/project/out/results.sarif",
149                    "message": "failed to write SARIF file '/project/out/results.sarif'"
150                }
151            }
152        });
153
154        strip_root_prefix(&mut value, "/project/");
155
156        assert_eq!(value["path"], "src/index.ts");
157        let entry = &value["request_outcomes"]["sarif-file"];
158        assert_eq!(entry["requested"], "/project/out/results.sarif");
159        assert_eq!(
160            entry["message"],
161            "failed to write SARIF file '/project/out/results.sarif'"
162        );
163    }
164
165    #[test]
166    fn leaves_non_matching_strings_unchanged() {
167        let mut value = json!("src/index.ts");
168
169        strip_root_prefix(&mut value, "/project/");
170
171        assert_eq!(value, json!("src/index.ts"));
172    }
173
174    #[test]
175    fn normalize_uri_rewrites_backslashes_and_brackets() {
176        assert_eq!(
177            normalize_uri("app\\[lang]\\posts\\[id].tsx"),
178            "app/%5Blang%5D/posts/%5Bid%5D.tsx"
179        );
180    }
181}