1use nexql_policy::{ObjectRef, PII_REDACTED, column_matches_pii_policy};
7use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
8use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime};
9use rust_decimal::Decimal;
10use serde_json::{Value, json};
11use tokio_postgres::types::{FromSql, Kind, Type};
12use uuid::Uuid;
13
14pub fn row_to_json(row: &tokio_postgres::Row) -> Value {
16 let mut map = serde_json::Map::new();
17 for (i, col) in row.columns().iter().enumerate() {
18 map.insert(col.name().to_string(), cell_to_json(row, i));
19 }
20 Value::Object(map)
21}
22
23pub fn rows_to_json_vec(rows: &[tokio_postgres::Row]) -> Vec<Value> {
25 rows.iter().map(row_to_json).collect()
26}
27
28pub fn rows_to_json_array(rows: &[tokio_postgres::Row]) -> Value {
30 Value::Array(rows_to_json_vec(rows))
31}
32
33pub fn redact_pii_in_rows(
35 rows: Vec<Value>,
36 pii_columns: &[String],
37 tables: &[ObjectRef],
38) -> (Vec<Value>, Vec<String>) {
39 if pii_columns.is_empty() || tables.is_empty() {
40 return (rows, Vec::new());
41 }
42 let mut redacted_cols = Vec::new();
43 let out = rows
44 .into_iter()
45 .map(|row| {
46 let mut obj = match row {
47 Value::Object(map) => map,
48 other => return other,
49 };
50 for (col, val) in obj.iter_mut() {
51 if column_matches_pii_policy(pii_columns, tables, col) {
52 *val = Value::String(PII_REDACTED.into());
53 if !redacted_cols.iter().any(|c| c == col) {
54 redacted_cols.push(col.clone());
55 }
56 }
57 }
58 Value::Object(obj)
59 })
60 .collect();
61 (out, redacted_cols)
62}
63
64pub fn redact_pii_in_payload(
66 mut payload: Value,
67 pii_columns: &[String],
68 tables: &[ObjectRef],
69) -> (Value, Vec<String>) {
70 if let Some(rows) = payload.get_mut("rows").and_then(|v| v.as_array_mut()) {
71 let taken = std::mem::take(rows);
72 let (redacted, cols) = redact_pii_in_rows(taken, pii_columns, tables);
73 *rows = redacted;
74 return (payload, cols);
75 }
76 if let Value::Array(rows) = &mut payload {
77 let taken = std::mem::take(rows);
78 let (redacted, cols) = redact_pii_in_rows(taken, pii_columns, tables);
79 *rows = redacted;
80 return (payload, cols);
81 }
82 (payload, Vec::new())
83}
84
85enum SqlNullness {
87 Null,
88 Value,
89}
90
91impl<'a> FromSql<'a> for SqlNullness {
92 fn from_sql(_: &Type, _: &'a [u8]) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
93 Ok(SqlNullness::Value)
94 }
95
96 fn from_sql_null(_: &Type) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
97 Ok(SqlNullness::Null)
98 }
99
100 fn accepts(_: &Type) -> bool {
101 true
102 }
103}
104
105fn try_cell<T, F>(row: &tokio_postgres::Row, idx: usize, map: F) -> Option<Value>
106where
107 T: for<'a> FromSql<'a>,
108 F: FnOnce(T) -> Value,
109{
110 match row.try_get::<_, Option<T>>(idx) {
111 Ok(Some(v)) => Some(map(v)),
112 Ok(None) => Some(Value::Null),
113 Err(_) => None,
114 }
115}
116
117pub fn cell_to_json(row: &tokio_postgres::Row, idx: usize) -> Value {
118 let col_type = row.columns()[idx].type_();
119 if matches!(row.try_get::<_, SqlNullness>(idx), Ok(SqlNullness::Null)) {
120 return Value::Null;
121 }
122
123 if let Kind::Array(elem) = col_type.kind() {
124 return array_cell_to_json(row, idx, elem);
125 }
126
127 if let Some(v) = match *col_type {
128 Type::BOOL => try_cell::<bool, _>(row, idx, |b| json!(b)),
129 Type::INT2 => try_cell::<i16, _>(row, idx, |n| json!(n)),
130 Type::INT4 | Type::OID => try_cell::<i32, _>(row, idx, |n| json!(n)),
131 Type::INT8 => try_cell::<i64, _>(row, idx, |n| json!(n)),
132 Type::FLOAT4 => try_cell::<f32, _>(row, idx, |n| json!(n)),
133 Type::FLOAT8 => try_cell::<f64, _>(row, idx, |n| json!(n)),
134 Type::TEXT | Type::VARCHAR | Type::BPCHAR | Type::NAME => {
135 try_cell::<String, _>(row, idx, Value::String)
136 }
137 Type::TIMESTAMP => try_cell::<NaiveDateTime, _>(row, idx, |t| {
138 json!(t.format("%Y-%m-%dT%H:%M:%S%.f").to_string())
139 }),
140 Type::TIMESTAMPTZ => {
141 try_cell::<DateTime<FixedOffset>, _>(row, idx, |t| json!(t.to_rfc3339()))
142 }
143 Type::DATE => {
144 try_cell::<NaiveDate, _>(row, idx, |d| json!(d.format("%Y-%m-%d").to_string()))
145 }
146 Type::TIME => {
147 try_cell::<NaiveTime, _>(row, idx, |t| json!(t.format("%H:%M:%S%.f").to_string()))
148 }
149 Type::UUID => try_cell::<Uuid, _>(row, idx, |u| json!(u.to_string())),
150 Type::JSON | Type::JSONB => try_cell::<Value, _>(row, idx, |j| j),
151 Type::NUMERIC => try_cell::<Decimal, _>(row, idx, |d| json!(d.to_string())),
152 Type::MONEY => try_cell::<i64, _>(row, idx, |v| json!(money_to_string(v))),
153 Type::BYTEA => try_cell::<Vec<u8>, _>(row, idx, |b| json!(BASE64.encode(b))),
154 _ => None,
155 } {
156 return v;
157 }
158
159 cell_to_json_untyped(row, idx, col_type)
160}
161
162fn array_cell_to_json(row: &tokio_postgres::Row, idx: usize, elem: &Type) -> Value {
163 let try_array = |result: Result<Option<Vec<Value>>, tokio_postgres::Error>| -> Option<Value> {
164 match result {
165 Ok(Some(items)) => Some(Value::Array(items)),
166 Ok(None) => Some(Value::Null),
167 Err(_) => None,
168 }
169 };
170
171 match *elem {
172 Type::BOOL => {
173 if let Some(v) = try_array(
174 row.try_get::<_, Option<Vec<bool>>>(idx)
175 .map(|v| v.map(|a| a.into_iter().map(|x| json!(x)).collect())),
176 ) {
177 return v;
178 }
179 }
180 Type::INT2 => {
181 if let Some(v) = try_array(
182 row.try_get::<_, Option<Vec<i16>>>(idx)
183 .map(|v| v.map(|a| a.into_iter().map(|x| json!(x)).collect())),
184 ) {
185 return v;
186 }
187 }
188 Type::INT4 | Type::OID => {
189 if let Some(v) = try_array(
190 row.try_get::<_, Option<Vec<i32>>>(idx)
191 .map(|v| v.map(|a| a.into_iter().map(|x| json!(x)).collect())),
192 ) {
193 return v;
194 }
195 }
196 Type::INT8 => {
197 if let Some(v) = try_array(
198 row.try_get::<_, Option<Vec<i64>>>(idx)
199 .map(|v| v.map(|a| a.into_iter().map(|x| json!(x)).collect())),
200 ) {
201 return v;
202 }
203 }
204 Type::FLOAT4 => {
205 if let Some(v) = try_array(
206 row.try_get::<_, Option<Vec<f32>>>(idx)
207 .map(|v| v.map(|a| a.into_iter().map(|n| json!(n)).collect())),
208 ) {
209 return v;
210 }
211 }
212 Type::FLOAT8 => {
213 if let Some(v) = try_array(
214 row.try_get::<_, Option<Vec<f64>>>(idx)
215 .map(|v| v.map(|a| a.into_iter().map(|n| json!(n)).collect())),
216 ) {
217 return v;
218 }
219 }
220 Type::TEXT | Type::VARCHAR | Type::BPCHAR | Type::NAME => {
221 if let Some(v) = try_array(
222 row.try_get::<_, Option<Vec<String>>>(idx)
223 .map(|v| v.map(|a| a.into_iter().map(Value::String).collect())),
224 ) {
225 return v;
226 }
227 }
228 Type::UUID => {
229 if let Some(v) = try_array(
230 row.try_get::<_, Option<Vec<Uuid>>>(idx)
231 .map(|v| v.map(|a| a.into_iter().map(|u| json!(u.to_string())).collect())),
232 ) {
233 return v;
234 }
235 }
236 Type::TIMESTAMP => {
237 if let Some(v) = try_array(row.try_get::<_, Option<Vec<NaiveDateTime>>>(idx).map(|v| {
238 v.map(|a| {
239 a.into_iter()
240 .map(|t| json!(t.format("%Y-%m-%dT%H:%M:%S%.f").to_string()))
241 .collect()
242 })
243 })) {
244 return v;
245 }
246 }
247 Type::TIMESTAMPTZ => {
248 if let Some(v) = try_array(
249 row.try_get::<_, Option<Vec<DateTime<FixedOffset>>>>(idx)
250 .map(|v| v.map(|a| a.into_iter().map(|t| json!(t.to_rfc3339())).collect())),
251 ) {
252 return v;
253 }
254 }
255 Type::DATE => {
256 if let Some(v) = try_array(row.try_get::<_, Option<Vec<NaiveDate>>>(idx).map(|v| {
257 v.map(|a| {
258 a.into_iter()
259 .map(|d| json!(d.format("%Y-%m-%d").to_string()))
260 .collect()
261 })
262 })) {
263 return v;
264 }
265 }
266 Type::JSON | Type::JSONB => {
267 if let Some(v) = try_array(row.try_get::<_, Option<Vec<Value>>>(idx)) {
268 return v;
269 }
270 }
271 Type::NUMERIC => {
272 if let Some(v) = try_array(
273 row.try_get::<_, Option<Vec<Decimal>>>(idx)
274 .map(|v| v.map(|a| a.into_iter().map(|d| json!(d.to_string())).collect())),
275 ) {
276 return v;
277 }
278 }
279 Type::MONEY => {
280 if let Some(v) = try_array(
281 row.try_get::<_, Option<Vec<i64>>>(idx)
282 .map(|v| v.map(|a| a.into_iter().map(|m| json!(money_to_string(m))).collect())),
283 ) {
284 return v;
285 }
286 }
287 Type::BYTEA => {
288 if let Some(v) = try_array(
289 row.try_get::<_, Option<Vec<Vec<u8>>>>(idx)
290 .map(|v| v.map(|a| a.into_iter().map(|b| json!(BASE64.encode(b))).collect())),
291 ) {
292 return v;
293 }
294 }
295 _ => {}
296 }
297
298 cell_to_json_untyped(row, idx, row.columns()[idx].type_())
299}
300
301fn money_to_string(v: i64) -> String {
303 let sign = if v < 0 { "-" } else { "" };
304 let abs = v.unsigned_abs();
305 format!("{}{}.{:04}", sign, abs / 10_000, abs % 10_000)
306}
307
308fn cell_to_json_untyped(row: &tokio_postgres::Row, idx: usize, pg_type: &Type) -> Value {
310 if let Ok(Some(s)) = row.try_get::<_, Option<String>>(idx) {
311 return Value::String(s);
312 }
313 json!({
314 "__untyped": true,
315 "type": pg_type.name()
316 })
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use serde_json::json;
323
324 #[test]
325 fn redact_pii_replaces_matching_columns() {
326 let rows = vec![json!({"id": 1, "ssn": "123-45-6789"})];
327 let tables = vec![ObjectRef::new("public", "users")];
328 let pii = vec!["public.users.ssn".into()];
329 let (out, cols) = redact_pii_in_rows(rows, &pii, &tables);
330 assert_eq!(cols, vec!["ssn"]);
331 assert_eq!(out[0]["ssn"], json!(PII_REDACTED));
332 assert_eq!(out[0]["id"], json!(1));
333 }
334}