qubit_redact/formats/json/
json_redaction_writer.rs1use qubit_budget::json::JsonDecodeSession;
11use qubit_json::decode::JsonDecodeErrorKind;
12use qubit_json::decode::JsonDecoder;
13use serde_json::Value;
14
15use super::JsonAdmissionError;
16use super::bounded_json_redaction::BoundedJsonRedaction;
17use super::bounded_json_redaction::redacted_json_value_bounded;
18use super::internal::JsonStructureSeed;
19use crate::output::log_escape::escape_log_control_characters;
20use crate::runtime::OperationSink;
21use crate::runtime::RenderedOperation;
22use crate::runtime::TextSession;
23use crate::runtime::runtime_session::RuntimeSession;
24
25pub(crate) fn admit_json_text_value(session: &mut dyn RuntimeSession, text: &str) -> Result<Value, JsonAdmissionError> {
31 admit_json_text_value_at_depth(session, text, 1)
32}
33
34pub(crate) fn admit_json_text_value_at_depth(
55 session: &mut dyn RuntimeSession,
56 text: &str,
57 root_depth: usize,
58) -> Result<Value, JsonAdmissionError> {
59 #[cfg(test)]
60 super::parse_counter::record_json_parse();
61 let mut rejected = false;
62 let (mut admission, json_budget) = session.split_json_admission();
63 let mut decoder = JsonDecoder::new(JsonDecodeSession::borrowing_value(json_budget));
64 let admitted = decoder.decode_seed_str(
65 JsonStructureSeed {
66 admission: &mut admission,
67 depth: root_depth,
68 collection_item: false,
69 rejected: &mut rejected,
70 },
71 text,
72 );
73 match admitted {
74 Ok(value) => Ok(value),
75 Err(_) if rejected => Err(JsonAdmissionError::Limit),
76 Err(error) if error.kind() == JsonDecodeErrorKind::Budget => {
77 session.record_json_value_limit_reached();
78 Err(JsonAdmissionError::Limit)
79 }
80 Err(_) => Err(JsonAdmissionError::Invalid),
81 }
82}
83
84#[must_use]
89pub(crate) fn passthrough_json_text_with_limit(text: &str, max_output_bytes: usize) -> RenderedOperation {
90 json_output_from_bounded(BoundedJsonRedaction::Complete(text.to_owned()), max_output_bytes)
91}
92
93#[must_use]
95pub(crate) fn json_output_from_bounded(
96 bounded: super::bounded_json_redaction::BoundedJsonRedaction,
97 max_output_bytes: usize,
98) -> RenderedOperation {
99 let (rendered, raw_truncated, invalid_json) = bounded.into_parts();
100 let output_text = escape_log_control_characters(std::borrow::Cow::Owned(rendered)).into_owned();
101 if output_text.len() > max_output_bytes {
102 let fallback = "<truncated>";
103 let mut output = if fallback.len() <= max_output_bytes {
104 OperationSink::truncated(fallback, crate::RedactionReason::OutputLimitReached)
105 } else {
106 OperationSink::exhausted(String::new())
107 };
108 if invalid_json {
109 output = output.with_reason(crate::RedactionReason::InvalidJson);
110 }
111 return output.finish();
112 }
113 if raw_truncated {
114 OperationSink::truncated(output_text, crate::RedactionReason::OutputLimitReached).finish()
115 } else if invalid_json {
116 OperationSink::complete_with_reason(output_text, crate::RedactionReason::InvalidJson).finish()
117 } else {
118 OperationSink::complete(output_text).finish()
119 }
120}
121
122pub struct JsonRedactionWriter<'session> {
140 pub(super) session: &'session mut TextSession,
142}
143
144impl<'session> JsonRedactionWriter<'session> {
145 #[must_use]
155 #[inline(always)]
156 pub(crate) const fn new(session: &'session mut TextSession) -> Self {
157 Self { session }
158 }
159
160 pub fn text(&mut self, text: &str) -> &mut Self {
172 if self.session.skip_aggregate_for_exhausted_output() {
173 return self;
174 }
175 if !self.session.admit_input(text.len()) {
176 self.session.append_rendered_operation(
177 crate::runtime::OperationSink::truncated("<truncated>", crate::RedactionReason::InputLimitReached)
178 .finish(),
179 );
180 return self;
181 }
182 let result = if self.session.policy().is_disabled() {
183 self.redact_text_direct(text)
184 } else {
185 match admit_json_text_value(self.session, text) {
186 Ok(value) => self.redact_value_direct(&value),
187 Err(JsonAdmissionError::Invalid) => {
188 invalid_json_output(self.session.policy(), self.session.remaining_output_bytes())
189 }
190 Err(JsonAdmissionError::Limit) => {
191 OperationSink::truncated("<truncated>", crate::RedactionReason::TraversalLimitReached).finish()
192 }
193 }
194 };
195 self.session.append_rendered_operation(result);
196 self
197 }
198
199 pub fn value(&mut self, value: &Value) -> &mut Self {
210 if self.session.skip_aggregate_for_exhausted_output() {
211 return self;
212 }
213 if !self.session.admit_json_value(value) {
214 self.session.append_rendered_operation(
215 OperationSink::truncated("<truncated>", crate::RedactionReason::TraversalLimitReached).finish(),
216 );
217 return self;
218 }
219 let result = self.redact_value_direct(value);
220 self.session.append_rendered_operation(result);
221 self
222 }
223}
224
225impl JsonRedactionWriter<'_> {
226 #[must_use]
232 pub(crate) fn redact_text_direct(&mut self, text: &str) -> RenderedOperation {
233 passthrough_json_text_with_limit(text, self.session.remaining_output_bytes())
234 }
235
236 #[must_use]
238 pub(crate) fn redact_value_direct(&mut self, value: &Value) -> RenderedOperation {
239 redact_json_value_with_limit(self.session.policy(), value, self.session.remaining_output_bytes())
240 }
241}
242
243#[must_use]
245pub(crate) fn redact_json_value_with_limit(
246 policy: &crate::RedactionPolicy,
247 value: &Value,
248 max_output_bytes: usize,
249) -> RenderedOperation {
250 json_output_from_bounded(
251 redacted_json_value_bounded(value, policy, max_output_bytes),
252 max_output_bytes,
253 )
254}
255
256pub(crate) fn invalid_json_output(policy: &crate::RedactionPolicy, max_output_bytes: usize) -> RenderedOperation {
258 json_output_from_bounded(
259 BoundedJsonRedaction::Invalid(policy.masking().mask_opaque(crate::Sensitivity::Secret).to_owned()),
260 max_output_bytes,
261 )
262}
263
264#[cfg(test)]
265mod tests {
266 use super::passthrough_json_text_with_limit;
267 use crate::RedactionCompletion;
268
269 #[test]
272 fn test_bounded_json_helper_never_exceeds_the_caller_allowance() {
273 let output = passthrough_json_text_with_limit(
274 r#"{"description":"this value is deliberately longer than the allowance"}"#,
275 16,
276 );
277
278 assert_eq!(output.completion(), RedactionCompletion::Truncated);
279 assert!(output.reasons().contains(crate::RedactionReason::OutputLimitReached));
280 assert!(output.text().len() <= 16);
281 }
282}