cloudiful_redactor/
input.rs1use serde::{Deserialize, Serialize};
2use std::ops::Range;
3
4#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
5#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
6#[serde(rename_all = "snake_case")]
7pub enum InputKind {
8 #[default]
9 Text,
10 GitDiff,
11}
12
13#[derive(Debug, Clone)]
14pub(crate) struct RedactableRange {
15 pub range: Range<usize>,
16 pub file_path: Option<String>,
17}
18
19pub(crate) fn redactable_ranges(
20 text: &str,
21 input_kind: InputKind,
22 source_path: Option<&str>,
23) -> Vec<RedactableRange> {
24 match input_kind {
25 InputKind::Text => {
26 let file_path = source_path.map(|s| s.to_string());
27 vec![RedactableRange {
28 range: 0..text.len(),
29 file_path,
30 }]
31 }
32 InputKind::GitDiff => git_diff_redactable_ranges(text),
33 }
34}
35
36fn git_diff_redactable_ranges(text: &str) -> Vec<RedactableRange> {
37 let mut ranges = Vec::new();
38 let mut offset = 0;
39 let mut in_hunk = false;
40 let mut in_binary_patch = false;
41 let mut current_file_path: Option<String> = None;
42
43 for line in text.split_inclusive('\n') {
44 if line.starts_with("diff --git ") {
45 in_hunk = false;
46 in_binary_patch = false;
47 current_file_path = parse_diff_git_path(line);
48 offset += line.len();
49 continue;
50 }
51
52 if line.starts_with("+++ ") || line.starts_with("--- ") {
53 current_file_path = parse_diff_path_line(line)
54 .or(current_file_path.take())
55 .or(current_file_path.clone());
56 if line.starts_with("+++ ") {
57 current_file_path = parse_diff_path_line(line).or(current_file_path);
58 }
59 offset += line.len();
60 continue;
61 }
62
63 if line.starts_with("GIT binary patch") || line.starts_with("Binary files ") {
64 in_hunk = false;
65 in_binary_patch = true;
66 offset += line.len();
67 continue;
68 }
69
70 if in_binary_patch {
71 offset += line.len();
72 continue;
73 }
74
75 if line.starts_with("@@") {
76 in_hunk = true;
77 offset += line.len();
78 continue;
79 }
80
81 if !in_hunk {
82 offset += line.len();
83 continue;
84 }
85
86 if line.starts_with("\\ No newline at end of file") {
87 offset += line.len();
88 continue;
89 }
90
91 if matches!(
92 line.as_bytes().first(),
93 Some(b'+') | Some(b'-') | Some(b' ')
94 ) && line.len() > 1
95 {
96 ranges.push(RedactableRange {
97 range: (offset + 1)..(offset + line.len()),
98 file_path: current_file_path.clone(),
99 });
100 }
101
102 offset += line.len();
103 }
104
105 ranges
106}
107
108fn parse_diff_git_path(line: &str) -> Option<String> {
109 let rest = line.strip_prefix("diff --git ")?;
110 let path_part = rest.split_whitespace().next()?;
111 let path = path_part
112 .strip_prefix("a/")
113 .unwrap_or(path_part)
114 .to_string();
115 Some(path)
116}
117
118fn parse_diff_path_line(line: &str) -> Option<String> {
119 let rest = if line.starts_with("+++ ") {
120 line.strip_prefix("+++ ")?
121 } else if line.starts_with("--- ") {
122 line.strip_prefix("--- ")?
123 } else {
124 return None;
125 };
126 let path = rest
127 .strip_prefix("b/")
128 .or_else(|| rest.strip_prefix("a/"))
129 .unwrap_or(rest);
130 let path = path.trim();
131 if path.is_empty() || path == "/dev/null" {
132 return None;
133 }
134 Some(path.to_string())
135}
136
137#[cfg(test)]
138mod tests {
139 use super::{InputKind, redactable_ranges};
140
141 #[test]
142 fn git_diff_ranges_only_cover_hunk_lines() {
143 let diff = concat!(
144 "diff --git a/config.yml b/config.yml\n",
145 "index 1111111..2222222 100644\n",
146 "--- a/config.yml\n",
147 "+++ b/config.yml\n",
148 "@@ -1,2 +1,3 @@\n",
149 "-old_secret=abc123\n",
150 "+new_secret=def456\n",
151 " unchanged=value\n",
152 );
153
154 let ranges = redactable_ranges(diff, InputKind::GitDiff, None);
155 let covered = ranges
156 .into_iter()
157 .map(|r| &diff[r.range])
158 .collect::<Vec<_>>();
159
160 assert_eq!(
161 covered,
162 vec![
163 "old_secret=abc123\n",
164 "new_secret=def456\n",
165 "unchanged=value\n"
166 ]
167 );
168 }
169
170 #[test]
171 fn text_mode_covers_entire_input() {
172 let text = "plain text";
173 let ranges = redactable_ranges(text, InputKind::Text, None);
174 assert_eq!(ranges.len(), 1);
175 assert_eq!(&text[ranges[0].range.clone()], text);
176 }
177
178 #[test]
179 fn text_mode_with_source_path() {
180 let text = "content";
181 let ranges = redactable_ranges(text, InputKind::Text, Some("secrets/env"));
182 assert_eq!(ranges.len(), 1);
183 assert_eq!(ranges[0].file_path.as_deref(), Some("secrets/env"));
184 }
185
186 #[test]
187 fn git_diff_extracts_file_path_from_headers() {
188 let diff = concat!(
189 "diff --git a/app/config.yml b/app/config.yml\n",
190 "index 1111111..2222222 100644\n",
191 "--- a/app/config.yml\n",
192 "+++ b/app/config.yml\n",
193 "@@ -1,1 +1,1 @@\n",
194 "-old_secret=abc123\n",
195 );
196
197 let ranges = redactable_ranges(diff, InputKind::GitDiff, None);
198 assert_eq!(ranges.len(), 1);
199 assert_eq!(ranges[0].file_path.as_deref(), Some("app/config.yml"));
200 }
201}