Skip to main content

snitch/
survival.rs

1//! Sourced claims about what platforms do to image metadata.
2//!
3//! This is a research index, not a blanket verification claim. A platform can read metadata during
4//! ingestion, retain it internally, show a label, and still omit it from the downloadable
5//! derivative. Those behaviours are recorded separately in each note, and an inference is rendered
6//! with a "?" so an expectation can never masquerade as a verified round trip.
7//!
8//! THE TABLE IS DATA, NOT CODE. It was exported verbatim from the Python that researched it, and
9//! it is baked in with `include_str!` so the binary is self-contained. `SNITCH_SURVIVAL` points at
10//! a replacement file for anyone who does the round trips and wants to publish better numbers
11//! without a rebuild.
12
13use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17const BUILTIN: &str = include_str!("../data/survival.json");
18
19#[derive(Debug, Clone, Deserialize, Serialize)]
20pub struct Layer {
21    pub key: String,
22    pub label: String,
23    pub description: String,
24}
25
26#[derive(Debug, Clone, Deserialize, Serialize)]
27pub struct Cell {
28    pub verdict: String,
29    pub evidence: String,
30    pub note: String,
31    #[serde(default)]
32    pub sources: Vec<Source>,
33}
34
35#[derive(Debug, Clone, Deserialize, Serialize)]
36pub struct Source {
37    pub title: String,
38    pub url: String,
39}
40
41#[derive(Debug, Clone, Deserialize, Serialize)]
42pub struct Symbols {
43    pub verdict: BTreeMap<String, String>,
44    pub evidence: BTreeMap<String, String>,
45}
46
47#[derive(Debug, Clone, Deserialize, Serialize)]
48pub struct Table {
49    pub researched: String,
50    pub advice: String,
51    pub how_to_verify: String,
52    pub legend: BTreeMap<String, String>,
53    pub symbols: Symbols,
54    pub layers: Vec<Layer>,
55    /// serde_json preserves insertion order with the preserve_order feature off only for maps it
56    /// controls, so platform order is taken from `platform_order` below instead of the map.
57    pub platforms: BTreeMap<String, BTreeMap<String, Cell>>,
58}
59
60/// The order the platforms are printed in, which is the order the research put them in and is not
61/// alphabetical. Kept beside the data rather than inferred from it.
62pub const PLATFORM_ORDER: [&str; 7] = [
63    "LinkedIn",
64    "Instagram",
65    "Facebook",
66    "X / Twitter",
67    "Reddit",
68    "Printables",
69    "Google Images",
70];
71
72impl Table {
73    pub fn load() -> Table {
74        // A replacement file that fails to parse is a mistake worth hearing about, but it must not
75        // stop the tool: the built-in table is always a valid answer.
76        if let Some(path) = std::env::var_os("SNITCH_SURVIVAL") {
77            match std::fs::read_to_string(&path)
78                .map_err(|e| e.to_string())
79                .and_then(|s| serde_json::from_str::<Table>(&s).map_err(|e| e.to_string()))
80            {
81                Ok(table) => return table,
82                Err(e) => eprintln!(
83                    "  SNITCH_SURVIVAL at {} could not be read, using the built-in table: {e}",
84                    std::path::Path::new(&path).display()
85                ),
86            }
87        }
88        serde_json::from_str(BUILTIN).expect("the built-in survival table is valid JSON")
89    }
90
91    pub fn ordered_platforms(&self) -> Vec<(&String, &BTreeMap<String, Cell>)> {
92        let mut out: Vec<_> = PLATFORM_ORDER
93            .iter()
94            .filter_map(|name| self.platforms.get_key_value(*name))
95            .collect();
96        // Anything a replacement file adds still gets printed, just after the known ones.
97        for (name, layers) in &self.platforms {
98            if !PLATFORM_ORDER.contains(&name.as_str()) {
99                out.push((name, layers));
100            }
101        }
102        out
103    }
104
105    pub fn display_cell(&self, cell: &Cell) -> String {
106        let evidence = self
107            .symbols
108            .evidence
109            .get(&cell.evidence)
110            .map(String::as_str)
111            .unwrap_or("?");
112        let verdict = self
113            .symbols
114            .verdict
115            .get(&cell.verdict)
116            .map(String::as_str)
117            .unwrap_or(cell.verdict.as_str());
118        format!("{evidence} {verdict}")
119    }
120
121    /// The machine-readable shape, matching what the Python's `as_dict` emitted.
122    pub fn as_json(&self, include_notes: bool, include_check: bool) -> serde_json::Value {
123        let mut platforms = serde_json::Map::new();
124        for (name, layers) in self.ordered_platforms() {
125            let mut entry = serde_json::Map::new();
126            for layer in &self.layers {
127                let Some(cell) = layers.get(&layer.key) else {
128                    continue;
129                };
130                let mut item = serde_json::json!({
131                    "verdict": cell.verdict,
132                    "evidence": cell.evidence,
133                    "live_tested": cell.evidence == "corroborated",
134                });
135                if include_notes {
136                    item["note"] = serde_json::json!(cell.note);
137                    item["sources"] = serde_json::to_value(&cell.sources).unwrap_or_default();
138                }
139                entry.insert(layer.key.clone(), item);
140            }
141            platforms.insert(name.clone(), serde_json::Value::Object(entry));
142        }
143        // Key order is the Python's insertion order, not alphabetical, because these reports get
144        // diffed against each other across an upload round trip.
145        let mut report = serde_json::json!({
146            "researched": self.researched,
147            "legend": {
148                "D": self.legend.get("D"),
149                "C": self.legend.get("C"),
150                "?": self.legend.get("?"),
151            },
152            "layers": self.layers,
153            "platforms": platforms,
154            "advice": self.advice,
155        });
156        if include_check {
157            report["how_to_verify"] = serde_json::json!(self.how_to_verify);
158        }
159        report
160    }
161}