1
//! Shared problem-report machinery: SCREENSHOT REDACTION and the report
2
//! bundle both the "Report a problem" dialog and the crash reporter build.
3
//!
4
//! The redaction is the privacy-critical part. A screenshot of a real
5
//! session shows real work — names, addresses, an open document. The user
6
//! must be able to black out anything before it leaves the machine, and the
7
//! blackout must be applied to the BYTES THAT ARE SENT, not merely drawn
8
//! over the preview: a rectangle painted in the dialog and forgotten at
9
//! send time would be a privacy hole disguised as a feature. Everything
10
//! here therefore operates on the PNG that is actually attached.
11

            
12
use alloc::{
13
    string::{String, ToString},
14
    vec::Vec,
15
};
16

            
17
use crate::cpurender::AzulPixmap;
18

            
19
/// A blackout rectangle in the coordinate space of the DISPLAYED preview
20
/// (logical pixels, origin at the preview's top-left).
21
#[derive(Debug, Copy, Clone, PartialEq, Default)]
22
#[repr(C)]
23
pub struct RedactRect {
24
    /// Left edge.
25
    pub x: f32,
26
    /// Top edge.
27
    pub y: f32,
28
    /// Width; negative widths are normalised by [`normalized`](Self::normalized).
29
    pub width: f32,
30
    /// Height; negative heights are normalised.
31
    pub height: f32,
32
}
33

            
34
impl RedactRect {
35
    /// A rectangle from two drag corners, in any order.
36
    #[must_use]
37
5
    pub fn from_corners(x0: f32, y0: f32, x1: f32, y1: f32) -> Self {
38
5
        Self {
39
5
            x: x0.min(x1),
40
5
            y: y0.min(y1),
41
5
            width: (x1 - x0).abs(),
42
5
            height: (y1 - y0).abs(),
43
5
        }
44
5
    }
45

            
46
    /// Positive-extent form (a drag up-and-left still covers what it drew).
47
    #[must_use]
48
5
    pub fn normalized(self) -> Self {
49
5
        Self::from_corners(self.x, self.y, self.x + self.width, self.y + self.height)
50
5
    }
51

            
52
    /// Whether the rectangle covers any area at all.
53
    #[must_use]
54
5
    pub fn is_empty(self) -> bool {
55
5
        self.width.abs() < 0.5 || self.height.abs() < 0.5
56
5
    }
57
}
58

            
59
/// Paints every rectangle solid black into the PNG and re-encodes it.
60
///
61
/// `scale` converts preview coordinates to image pixels (the preview is
62
/// usually shown smaller than the capture): `image_px = preview_px * scale`.
63
/// Rectangles are CLAMPED to the image — a drag that ran off the edge
64
/// blacks out to the edge instead of failing, because the user's intent
65
/// ("hide this") is unambiguous.
66
///
67
/// # Errors
68
///
69
/// Returns a description if the PNG cannot be decoded or re-encoded. The
70
/// caller must then treat the screenshot as UNREDACTED and refuse to send
71
/// it — silently attaching the original would defeat the whole feature.
72
3
pub fn redact_png(png: &[u8], rects: &[RedactRect], scale: f32) -> Result<Vec<u8>, String> {
73
3
    if rects.is_empty() {
74
        return Ok(png.to_vec());
75
3
    }
76
3
    let mut pixmap = AzulPixmap::decode_png(png)?;
77
3
    let (w, h) = (pixmap.width(), pixmap.height());
78
3
    if w == 0 || h == 0 {
79
        return Err("screenshot has no pixels".to_string());
80
3
    }
81
3
    let scale = if scale.is_finite() && scale > 0.0 {
82
3
        scale
83
    } else {
84
        1.0
85
    };
86

            
87
8
    for rect in rects {
88
5
        let r = rect.normalized();
89
5
        if r.is_empty() {
90
1
            continue;
91
4
        }
92
        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
93
4
        let x0 = ((r.x * scale).max(0.0) as u32).min(w);
94
        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
95
4
        let y0 = ((r.y * scale).max(0.0) as u32).min(h);
96
        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
97
4
        let x1 = (((r.x + r.width) * scale).max(0.0) as u32).min(w);
98
        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
99
4
        let y1 = (((r.y + r.height) * scale).max(0.0) as u32).min(h);
100
4
        if x1 <= x0 || y1 <= y0 {
101
            continue;
102
4
        }
103
4
        let data = pixmap.data_mut();
104
35
        for y in y0..y1 {
105
35
            let row = (y as usize) * (w as usize) * 4;
106
526
            for x in x0..x1 {
107
526
                let i = row + (x as usize) * 4;
108
526
                data[i] = 0;
109
526
                data[i + 1] = 0;
110
526
                data[i + 2] = 0;
111
526
                data[i + 3] = 255;
112
526
            }
113
        }
114
    }
115
3
    pixmap.encode_png()
116
3
}
117

            
118
/// Crops a PNG to a rectangle in IMAGE pixel coordinates, clamped to the
119
/// image. Used by `CallbackInfo::take_screenshot_of_node`, which renders the
120
/// whole window and then keeps one node's box.
121
///
122
/// # Errors
123
///
124
/// Returns a description if the PNG cannot be decoded, the rectangle is
125
/// empty after clamping, or the result cannot be encoded.
126
3
pub fn crop_png(png: &[u8], x: u32, y: u32, width: u32, height: u32) -> Result<Vec<u8>, String> {
127
3
    let src = AzulPixmap::decode_png(png)?;
128
3
    let (sw, sh) = (src.width(), src.height());
129
3
    let x0 = x.min(sw);
130
3
    let y0 = y.min(sh);
131
3
    let x1 = x.saturating_add(width).min(sw);
132
3
    let y1 = y.saturating_add(height).min(sh);
133
3
    if x1 <= x0 || y1 <= y0 {
134
1
        return Err(alloc::format!(
135
1
            "crop rectangle {x},{y} {width}x{height} lies outside the {sw}x{sh} screenshot"
136
1
        ));
137
2
    }
138
2
    let (cw, ch) = (x1 - x0, y1 - y0);
139
2
    let mut out = AzulPixmap::new(cw, ch).ok_or_else(|| "pixmap alloc failed".to_string())?;
140
    {
141
2
        let srcd = src.data();
142
2
        let dst = out.data_mut();
143
11
        for row in 0..ch {
144
11
            let s = ((y0 + row) as usize) * (sw as usize) * 4 + (x0 as usize) * 4;
145
11
            let d = (row as usize) * (cw as usize) * 4;
146
11
            let len = (cw as usize) * 4;
147
11
            dst[d..d + len].copy_from_slice(&srcd[s..s + len]);
148
11
        }
149
    }
150
2
    out.encode_png()
151
3
}
152

            
153
/// Everything a report can carry. Each section is opt-in in the dialog, and
154
/// a section the user did not tick is `None` HERE — not filtered later —
155
/// so there is exactly one place that decides what leaves the machine.
156
#[derive(Debug, Clone, Default)]
157
pub struct ReportBundle {
158
    /// What the user typed.
159
    pub message: String,
160
    /// OS / CPU / memory summary.
161
    pub sysinfo: Option<String>,
162
    /// The action journal as JSON ("include recent actions").
163
    pub recent_actions: Option<String>,
164
    /// The app's serialized state ("include app data", default OFF).
165
    pub app_data: Option<String>,
166
    /// The REDACTED screenshot, PNG.
167
    pub screenshot_png: Option<Vec<u8>>,
168
}
169

            
170
impl ReportBundle {
171
    /// The human-readable body. Sections the user declined are absent, not
172
    /// empty-headed: a report that lists "System information:" with nothing
173
    /// under it reads like data was lost.
174
    #[must_use]
175
1
    pub fn to_text(&self) -> String {
176
1
        let mut out = String::new();
177
1
        out.push_str(self.message.trim());
178
1
        out.push_str("\n\n");
179
1
        if let Some(sys) = &self.sysinfo {
180
            out.push_str("--- System information ---\n");
181
            out.push_str(sys);
182
            out.push_str("\n\n");
183
1
        }
184
1
        if let Some(actions) = &self.recent_actions {
185
            out.push_str("--- Recent actions ---\n");
186
            out.push_str(actions);
187
            out.push_str("\n\n");
188
1
        }
189
1
        if self.app_data.is_some() {
190
            out.push_str("--- Application data is attached as app-data.json ---\n\n");
191
1
        }
192
1
        if self.screenshot_png.is_some() {
193
            out.push_str("--- A screenshot is attached ---\n");
194
1
        }
195
1
        out
196
1
    }
197

            
198
    /// `(filename, bytes)` for every attachment the user consented to.
199
    #[must_use]
200
2
    pub fn attachments(&self) -> Vec<(String, Vec<u8>)> {
201
2
        let mut out = Vec::new();
202
2
        if let Some(png) = &self.screenshot_png {
203
1
            out.push(("screenshot.png".to_string(), png.clone()));
204
1
        }
205
2
        if let Some(actions) = &self.recent_actions {
206
1
            out.push((
207
1
                "recent-actions.json".to_string(),
208
1
                actions.clone().into_bytes(),
209
1
            ));
210
1
        }
211
2
        if let Some(data) = &self.app_data {
212
1
            out.push(("app-data.json".to_string(), data.clone().into_bytes()));
213
1
        }
214
2
        out
215
2
    }
216
}
217

            
218
#[cfg(test)]
219
mod tests {
220
    use super::*;
221

            
222
4
    fn white_png(w: u32, h: u32) -> Vec<u8> {
223
4
        let mut p = AzulPixmap::new(w, h).expect("pixmap");
224
4
        p.fill(255, 255, 255, 255);
225
4
        p.encode_png().expect("encode")
226
4
    }
227

            
228
10
    fn pixel(png: &[u8], x: u32, y: u32) -> (u8, u8, u8, u8) {
229
10
        let p = AzulPixmap::decode_png(png).expect("decode");
230
10
        let i = (y as usize) * (p.width() as usize) * 4 + (x as usize) * 4;
231
10
        let d = p.data();
232
10
        (d[i], d[i + 1], d[i + 2], d[i + 3])
233
10
    }
234

            
235
    /// LAW: redaction blacks out the SENT BYTES, and only inside the
236
    /// rectangle. A preview-only blackout would be a privacy hole.
237
    #[test]
238
1
    fn redaction_blacks_out_the_attached_pixels_and_nothing_else() {
239
1
        let png = white_png(40, 20);
240
1
        let out = redact_png(
241
1
            &png,
242
1
            &[RedactRect {
243
1
                x: 10.0,
244
1
                y: 5.0,
245
1
                width: 10.0,
246
1
                height: 5.0,
247
1
            }],
248
            1.0,
249
        )
250
1
        .expect("redaction must succeed");
251

            
252
1
        assert_eq!(pixel(&out, 10, 5), (0, 0, 0, 255), "top-left of the rect");
253
1
        assert_eq!(pixel(&out, 19, 9), (0, 0, 0, 255), "bottom-right of the rect");
254
1
        assert_eq!(
255
1
            pixel(&out, 9, 5),
256
            (255, 255, 255, 255),
257
            "the pixel LEFT of the rect must be untouched"
258
        );
259
1
        assert_eq!(
260
1
            pixel(&out, 20, 10),
261
            (255, 255, 255, 255),
262
            "the pixel past the rect must be untouched"
263
        );
264
1
    }
265

            
266
    /// LAW: the preview is smaller than the capture, so a rectangle drawn
267
    /// on it must scale to the right IMAGE pixels — an unscaled blackout
268
    /// would cover the wrong area and leave the secret visible.
269
    #[test]
270
1
    fn redaction_scales_preview_coordinates_to_image_pixels() {
271
1
        let png = white_png(40, 40);
272
        // Preview is half size: a 0..10 preview rect covers 0..20 image px.
273
1
        let out = redact_png(
274
1
            &png,
275
1
            &[RedactRect {
276
1
                x: 0.0,
277
1
                y: 0.0,
278
1
                width: 10.0,
279
1
                height: 10.0,
280
1
            }],
281
            2.0,
282
        )
283
1
        .expect("redaction must succeed");
284
1
        assert_eq!(pixel(&out, 19, 19), (0, 0, 0, 255), "scaled rect must reach 19,19");
285
1
        assert_eq!(
286
1
            pixel(&out, 20, 20),
287
            (255, 255, 255, 255),
288
            "and must stop at 20,20"
289
        );
290
1
    }
291

            
292
    /// A drag that ran off the edge, a backwards drag and a zero-area drag
293
    /// must all behave, not panic.
294
    #[test]
295
1
    fn degenerate_rectangles_are_clamped_not_fatal() {
296
1
        let png = white_png(10, 10);
297
1
        let out = redact_png(
298
1
            &png,
299
1
            &[
300
1
                // Starts off-canvas and reaches IN: rows 0..4 must go black.
301
1
                RedactRect { x: -50.0, y: -2.0, width: 500.0, height: 6.0 },
302
1
                RedactRect { x: 8.0, y: 8.0, width: -6.0, height: -6.0 },
303
1
                RedactRect { x: 1.0, y: 1.0, width: 0.0, height: 0.0 },
304
1
            ],
305
            1.0,
306
        )
307
1
        .expect("degenerate rectangles must not fail the redaction");
308
1
        assert_eq!(pixel(&out, 0, 0), (0, 0, 0, 255), "clamped rect covers the top row");
309
1
        assert_eq!(pixel(&out, 9, 3), (0, 0, 0, 255), "…across the full width");
310
1
        assert_eq!(
311
1
            pixel(&out, 9, 5),
312
            (255, 255, 255, 255),
313
            "…and stops where the rect ends"
314
        );
315
1
        assert_eq!(pixel(&out, 3, 3), (0, 0, 0, 255), "backwards drag still covers its area");
316
1
    }
317

            
318
    #[test]
319
1
    fn crop_keeps_the_requested_box_and_clamps_the_rest() {
320
1
        let png = white_png(40, 20);
321
1
        let cropped = crop_png(&png, 10, 5, 8, 6).expect("crop");
322
1
        let p = AzulPixmap::decode_png(&cropped).expect("decode");
323
1
        assert_eq!((p.width(), p.height()), (8, 6));
324
        // A box hanging off the edge clamps to what exists.
325
1
        let clamped = crop_png(&png, 35, 15, 100, 100).expect("clamped crop");
326
1
        let p = AzulPixmap::decode_png(&clamped).expect("decode");
327
1
        assert_eq!((p.width(), p.height()), (5, 5));
328
        // Fully outside is an error, not an empty image.
329
1
        assert!(crop_png(&png, 100, 100, 10, 10).is_err());
330
1
    }
331

            
332
    /// LAW: a section the user did not tick must not appear anywhere in the
333
    /// report — not as an empty heading, not as an attachment.
334
    #[test]
335
1
    fn declined_sections_are_absent_from_text_and_attachments() {
336
1
        let bundle = ReportBundle {
337
1
            message: "it broke".to_string(),
338
1
            sysinfo: None,
339
1
            recent_actions: None,
340
1
            app_data: None,
341
1
            screenshot_png: None,
342
1
        };
343
1
        let text = bundle.to_text();
344
1
        assert!(text.contains("it broke"));
345
1
        assert!(!text.contains("System information"));
346
1
        assert!(!text.contains("Recent actions"));
347
1
        assert!(!text.contains("Application data"));
348
1
        assert!(!text.contains("screenshot"));
349
1
        assert!(bundle.attachments().is_empty());
350

            
351
1
        let full = ReportBundle {
352
1
            message: "it broke".to_string(),
353
1
            sysinfo: Some("linux".to_string()),
354
1
            recent_actions: Some("[]".to_string()),
355
1
            app_data: Some("{}".to_string()),
356
1
            screenshot_png: Some(vec![1, 2, 3]),
357
1
        };
358
1
        let names: Vec<String> = full.attachments().into_iter().map(|(n, _)| n).collect();
359
1
        assert_eq!(
360
            names,
361
1
            vec![
362
1
                "screenshot.png".to_string(),
363
1
                "recent-actions.json".to_string(),
364
1
                "app-data.json".to_string()
365
            ]
366
        );
367
1
    }
368
}