scour_secrets/processor/
columns.rs1use crate::error::Result;
32use crate::processor::limits::DEFAULT_INPUT_SIZE;
33use crate::processor::{
34 apply_edits, check_size_and_decode, edit_token, find_matching_rule, FileTypeProfile, Processor,
35 Replacement,
36};
37use crate::store::MappingStore;
38
39pub struct ColumnsProcessor;
41
42fn token_spans(line: &str) -> Vec<(usize, usize)> {
44 let bytes = line.as_bytes();
45 let mut spans = Vec::new();
46 let mut i = 0usize;
47 while i < bytes.len() {
48 if bytes[i].is_ascii_whitespace() {
49 i += 1;
50 continue;
51 }
52 let start = i;
53 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
54 i += 1;
55 }
56 spans.push((start, i));
57 }
58 spans
59}
60
61fn matched_header_columns(line: &str, profile: &FileTypeProfile) -> Vec<(usize, String)> {
64 token_spans(line)
65 .iter()
66 .enumerate()
67 .filter_map(|(idx, &(s, e))| {
68 let name = &line[s..e];
69 find_matching_rule(name, profile).map(|_| (idx, name.to_string()))
70 })
71 .collect()
72}
73
74fn compute_edits(
77 text: &str,
78 profile: &FileTypeProfile,
79 store: &MappingStore,
80) -> Result<Vec<Replacement>> {
81 let mut edits = Vec::new();
82 let mut header: Option<(Vec<(usize, String)>, usize)> = None;
84
85 let mut line_start = 0usize;
86 for line in text.split_inclusive('\n') {
87 let body = line.trim_end_matches(['\n', '\r']);
88 let matched = matched_header_columns(body, profile);
89 if !matched.is_empty() {
90 header = Some((matched, token_spans(body).len()));
91 line_start += line.len();
92 continue;
93 }
94 if let Some((columns, width)) = &header {
95 let spans = token_spans(body);
96 if spans.len() >= *width {
98 for (idx, name) in columns {
99 let (s, e) = spans[*idx];
100 let value = &body[s..e];
101 if let Some(token) = edit_token(name, name, value, profile, store)? {
102 edits.push(Replacement::new(line_start + s, line_start + e, token));
103 }
104 }
105 }
106 }
107 line_start += line.len();
108 }
109 Ok(edits)
110}
111
112impl Processor for ColumnsProcessor {
113 fn name(&self) -> &'static str {
114 "columns"
115 }
116
117 fn can_handle(&self, _content: &[u8], profile: &FileTypeProfile) -> bool {
118 profile.processor == "columns"
119 }
120
121 fn process(
122 &self,
123 content: &[u8],
124 profile: &FileTypeProfile,
125 store: &MappingStore,
126 ) -> Result<Vec<u8>> {
127 let text = check_size_and_decode(content, "columns", DEFAULT_INPUT_SIZE)?;
128 let edits = compute_edits(text, profile, store)?;
129 Ok(apply_edits(content, edits))
130 }
131
132 fn process_to_edits(
133 &self,
134 content: &[u8],
135 profile: &FileTypeProfile,
136 store: &MappingStore,
137 ) -> Result<Option<Vec<Replacement>>> {
138 let text = check_size_and_decode(content, "columns", DEFAULT_INPUT_SIZE)?;
139 Ok(Some(compute_edits(text, profile, store)?))
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use crate::category::Category;
147 use crate::generator::HmacGenerator;
148 use crate::processor::profile::FieldRule;
149 use std::sync::Arc;
150
151 fn store() -> MappingStore {
152 MappingStore::new(Arc::new(HmacGenerator::new([42u8; 32])), None)
153 }
154
155 fn user_profile(min_length: usize) -> FileTypeProfile {
156 FileTypeProfile::new(
157 "columns",
158 vec![FieldRule::new("USER")
159 .with_category(Category::Name)
160 .with_min_length(min_length)],
161 )
162 }
163
164 #[test]
165 fn ps_user_column_is_replaced() {
166 let input = b"USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\n\
167 git 1 0.0 0.5 167576 11132 ? Ss Jun24 0:11 /sbin/init\n\
168 jdoeworth 1234 2.0 1.1 981432 22208 ? Sl Jun24 3:02 ruby puma\n";
169 let st = store();
170 let out = ColumnsProcessor
171 .process(input, &user_profile(4), &st)
172 .unwrap();
173 let out = String::from_utf8(out).unwrap();
174 assert!(!out.contains("jdoeworth"), "username replaced: {out}");
175 assert!(out.contains("git "), "min_length keeps 'git': {out}");
176 assert!(
177 out.contains("USER") && out.contains("/sbin/init") && out.contains("ruby puma"),
178 "header and commands preserved: {out}"
179 );
180 }
181
182 #[test]
183 fn top_preamble_lines_pass_through() {
184 let input = b"top - 04:01:46 up 5 days, 3:22, 1 user, load average: 0.10, 0.20, 0.30\n\
187 Tasks: 270 total, 1 running, 269 sleeping, 0 stopped, 0 zombie\n\
188 \n\
189 \x20 PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND\n\
190 \x20 1234 jdoeworth 20 0 981432 22208 11132 S 2.0 1.1 3:02.11 ruby\n";
191 let st = store();
192 let out = ColumnsProcessor
193 .process(input, &user_profile(4), &st)
194 .unwrap();
195 let out = String::from_utf8(out).unwrap();
196 assert!(!out.contains("jdoeworth"), "username replaced: {out}");
197 assert!(
198 out.contains("load average: 0.10, 0.20, 0.30") && out.contains("Tasks: 270 total"),
199 "preamble untouched: {out}"
200 );
201 }
202
203 #[test]
204 fn lines_before_header_are_untouched() {
205 let input = b"no header yet alice bob\nUSER PID\nalice 12\n";
206 let st = store();
207 let out = ColumnsProcessor
208 .process(input, &user_profile(4), &st)
209 .unwrap();
210 let out = String::from_utf8(out).unwrap();
211 assert!(
212 out.starts_with("no header yet alice bob\n"),
213 "pre-header lines pass through: {out}"
214 );
215 assert!(!out.contains("\nalice 12"), "data row replaced: {out}");
216 }
217
218 #[test]
219 fn repeated_headers_rekey_columns() {
220 let input = b"USER PID\nalice 12\nUSER PID\nbobby 34\n";
222 let st = store();
223 let out = ColumnsProcessor
224 .process(input, &user_profile(4), &st)
225 .unwrap();
226 let out = String::from_utf8(out).unwrap();
227 assert!(
228 !out.contains("alice") && !out.contains("bobby"),
229 "both blocks replaced: {out}"
230 );
231 }
232}