Skip to main content

kopiur_api/
recorded.rs

1//! Recorded snapshot metadata — the `kopiur-meta` kopia tag.
2//!
3//! Every produced backup records the mover identity it ran as (uid/gid/fsGroup
4//! plus its provenance) as a compact JSON value under the [`KOPIUR_META_TAG`]
5//! snapshot tag, so a later restore — possibly on a rebuilt cluster where the
6//! workload no longer exists — can reproduce the identity the data expects.
7//! The catalog scan decodes the tag back into `Snapshot.status.recorded`.
8//!
9//! ## Schema write policy
10//!
11//! Writers emit the **lowest** schema number that represents the data
12//! ([`KOPIUR_META_SCHEMA_V1`] today); the schema is bumped only for a semantic
13//! change an old reader would misinterpret, never for additive fields (readers
14//! accept unknown JSON fields). An older operator reading a newer schema
15//! degrades to recorded-absent ([`MetaTagDecode::UnsupportedSchema`]) — it never
16//! errors. This matters for shared-repository multi-cluster topologies running
17//! mixed operator versions.
18//!
19//! ## Decode is graceful, always
20//!
21//! [`decode_meta_tag`] never returns an error and never panics: the tag value is
22//! repository data, writable by anyone holding repository credentials (a foreign
23//! cluster, a NAS admin, a compromised replication peer). A malformed value must
24//! degrade to [`MetaTagDecode::Malformed`] — one aggregated per-scan count, never
25//! a poisoned catalog scan or a per-entry log line a foreign writer could
26//! amplify.
27
28use schemars::JsonSchema;
29use serde::{Deserialize, Serialize};
30use std::collections::BTreeMap;
31
32/// The kopia snapshot-tag key kopiur records its metadata under. Deliberately
33/// colon-free: kopia splits `--tags` on the FIRST colon, so a colon-free key is
34/// collision-proof against the legacy `kopiur:config:<name>` tag (stored by
35/// kopia as manifest key `tag:kopiur`, value `config:<name>`) and can never trip
36/// kopia's duplicate-tag-key create failure.
37pub const KOPIUR_META_TAG: &str = "kopiur-meta";
38
39/// The current (and only) `kopiur-meta` schema version. See the module docs for
40/// the write policy: writers emit the lowest schema representing the data.
41pub const KOPIUR_META_SCHEMA_V1: i64 = 1;
42
43/// Where the recorded mover identity came from — which layer of the
44/// security-context ladder pinned the effective UID this run recorded.
45///
46/// Only [`RecordedSrc::Inherited`] means the identity actually **tracked the
47/// workload** (it was read from a live workload pod and survived the merge). A
48/// restore consuming recorded metadata keys its honesty on this: an
49/// `explicit`/`defaults` identity is reproduced faithfully but was never
50/// workload-derived.
51#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema)]
52#[serde(rename_all = "lowercase")]
53pub enum RecordedSrc {
54    /// The identity was inherited from a live workload pod
55    /// (`inheritSecurityContextFrom`) and is the one the mover ran as.
56    Inherited,
57    /// The recipe's explicit `mover.securityContext`/`podSecurityContext`
58    /// pinned the identity (including the inherit-fallback case, where the
59    /// explicit context stood in for an unresolvable workload).
60    Explicit,
61    /// The identity came from lower layers (the repository's `moverDefaults`
62    /// or the hardened base), or nothing pinned one at all.
63    Defaults,
64    /// A provenance string this operator version does not know (written by a
65    /// newer kopiur). Decodes gracefully instead of poisoning a catalog scan;
66    /// never written by this version.
67    #[serde(other)]
68    Unknown,
69}
70
71/// The mover identity recorded on a kopia snapshot at backup time — the decoded
72/// value of the [`KOPIUR_META_TAG`] snapshot tag.
73#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
74#[serde(rename_all = "camelCase")]
75pub struct RecordedSnapshotMeta {
76    /// The `kopiur-meta` schema version this value was written under. Writers
77    /// emit the lowest schema that represents the data (currently
78    /// [`KOPIUR_META_SCHEMA_V1`]); readers reject only a schema NEWER than they
79    /// understand (degrading to recorded-absent, never an error).
80    pub schema: i64,
81    /// Which layer pinned the recorded identity. Only `inherited` means the
82    /// identity tracked the workload.
83    pub src: RecordedSrc,
84    /// The resolved effective `runAsUser` the mover ran as at backup time
85    /// (container `runAsUser`, else pod). Absent = no layer pinned a UID, so
86    /// the mover's UID was image-determined.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub uid: Option<i64>,
89    /// The resolved effective `runAsGroup` the mover ran as at backup time
90    /// (container `runAsGroup`, else pod). Absent = image-determined.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub gid: Option<i64>,
93    /// The resolved pod-level `fsGroup` the mover ran with (the hardened
94    /// default is `65532`). Absent = no layer set one.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub fs_group: Option<i64>,
97}
98
99/// What [`decode_meta_tag`] found in a snapshot's tags map. Never an `Err`: a
100/// bad tag value is repository data and must degrade, not fail a scan.
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub enum MetaTagDecode {
103    /// No `kopiur-meta` tag on the snapshot (pre-feature or foreign backup).
104    Absent,
105    /// A well-formed, supported-schema value.
106    Decoded(RecordedSnapshotMeta),
107    /// A well-formed value written under a schema newer than this reader
108    /// understands — degrade to recorded-absent (aggregate-count the event).
109    UnsupportedSchema {
110        /// The schema number the writer declared.
111        schema: i64,
112    },
113    /// The tag value is present but not decodable (bad JSON, missing/invalid
114    /// `schema` or `src`). Degrade to recorded-absent (aggregate-count it).
115    Malformed {
116        /// A short, bounded reason for the scan's aggregated diagnostics.
117        reason: String,
118    },
119}
120
121/// Encode recorded metadata as the compact single-line JSON value stored under
122/// the [`KOPIUR_META_TAG`] snapshot tag.
123pub fn encode_meta_tag(meta: &RecordedSnapshotMeta) -> String {
124    serde_json::to_string(meta).unwrap_or_else(|e| {
125        // Serialization of a plain scalar struct cannot fail; defensive only.
126        unreachable!("RecordedSnapshotMeta serialization failed: {e}")
127    })
128}
129
130/// Decode the [`KOPIUR_META_TAG`] value out of a snapshot `tags` map.
131///
132/// Accepts BOTH the manifest key shape kopia stores (`tag:kopiur-meta`, see
133/// [`kopiur-kopia`'s `user_tags`]) and the bare `kopiur-meta` key (already
134/// prefix-stripped, or normalized by the mover's result wire) — so every read
135/// path funnels through this one decoder regardless of which wire the entry
136/// rode. Unknown extra JSON fields are accepted (forward compatibility); a
137/// schema newer than [`KOPIUR_META_SCHEMA_V1`] degrades to
138/// [`MetaTagDecode::UnsupportedSchema`]; anything undecodable degrades to
139/// [`MetaTagDecode::Malformed`]. Never an error, never a panic.
140pub fn decode_meta_tag(tags: &BTreeMap<String, String>) -> MetaTagDecode {
141    // Prefer the raw manifest key; fall back to the bare (stripped/normalized)
142    // key. Both present and disagreeing cannot happen from kopia's own output.
143    let prefixed = format!("tag:{KOPIUR_META_TAG}");
144    let Some(value) = tags.get(&prefixed).or_else(|| tags.get(KOPIUR_META_TAG)) else {
145        return MetaTagDecode::Absent;
146    };
147    let parsed: serde_json::Value = match serde_json::from_str(value) {
148        Ok(v) => v,
149        Err(e) => {
150            return MetaTagDecode::Malformed {
151                reason: format!("invalid JSON: {e}"),
152            };
153        }
154    };
155    let Some(schema) = parsed.get("schema").and_then(serde_json::Value::as_i64) else {
156        return MetaTagDecode::Malformed {
157            reason: "missing or non-integer `schema` field".to_string(),
158        };
159    };
160    if schema > KOPIUR_META_SCHEMA_V1 {
161        return MetaTagDecode::UnsupportedSchema { schema };
162    }
163    match serde_json::from_value::<RecordedSnapshotMeta>(parsed) {
164        Ok(meta) => MetaTagDecode::Decoded(meta),
165        Err(e) => MetaTagDecode::Malformed {
166            reason: format!("schema {schema} value did not decode: {e}"),
167        },
168    }
169}
170
171/// Truncate `s` to at most `max_bytes` bytes on a `char` boundary (never
172/// panics, never splits a multi-byte character). Shared by the catalog's
173/// description cap and the mover's result-wire bound — foreign-writer-controlled
174/// strings must never 4xx a CR create or inflate the result ConfigMap.
175pub fn truncate_utf8(s: &str, max_bytes: usize) -> &str {
176    if s.len() <= max_bytes {
177        return s;
178    }
179    let mut end = max_bytes;
180    while end > 0 && !s.is_char_boundary(end) {
181        end -= 1;
182    }
183    &s[..end]
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    fn meta(
191        src: RecordedSrc,
192        uid: Option<i64>,
193        gid: Option<i64>,
194        fs: Option<i64>,
195    ) -> RecordedSnapshotMeta {
196        RecordedSnapshotMeta {
197            schema: KOPIUR_META_SCHEMA_V1,
198            src,
199            uid,
200            gid,
201            fs_group: fs,
202        }
203    }
204
205    fn tags_with(key: &str, value: &str) -> BTreeMap<String, String> {
206        BTreeMap::from([(key.to_string(), value.to_string())])
207    }
208
209    #[test]
210    fn encode_is_compact_single_line_camel_case() {
211        let m = meta(RecordedSrc::Inherited, Some(1000), Some(1000), Some(65532));
212        let s = encode_meta_tag(&m);
213        assert!(!s.contains('\n'), "single line: {s}");
214        assert!(!s.contains(' '), "compact: {s}");
215        assert_eq!(
216            s,
217            r#"{"schema":1,"src":"inherited","uid":1000,"gid":1000,"fsGroup":65532}"#
218        );
219    }
220
221    #[test]
222    fn encode_elides_absent_optionals() {
223        let m = meta(RecordedSrc::Defaults, None, None, None);
224        assert_eq!(encode_meta_tag(&m), r#"{"schema":1,"src":"defaults"}"#);
225    }
226
227    #[test]
228    fn round_trips_through_both_key_shapes() {
229        let m = meta(RecordedSrc::Explicit, Some(3001), None, Some(3001));
230        let value = encode_meta_tag(&m);
231        // The raw manifest key shape (`tag:` prefix, kopia 0.23.1-verified).
232        let prefixed = tags_with("tag:kopiur-meta", &value);
233        assert_eq!(
234            decode_meta_tag(&prefixed),
235            MetaTagDecode::Decoded(m.clone())
236        );
237        // The bare key shape (prefix-stripped / mover-normalized wire).
238        let bare = tags_with("kopiur-meta", &value);
239        assert_eq!(decode_meta_tag(&bare), MetaTagDecode::Decoded(m));
240    }
241
242    #[test]
243    fn absent_tag_decodes_absent() {
244        assert_eq!(decode_meta_tag(&BTreeMap::new()), MetaTagDecode::Absent);
245        // Other tags present, ours absent.
246        let other = tags_with("tag:kopiur", "config:nightly");
247        assert_eq!(decode_meta_tag(&other), MetaTagDecode::Absent);
248    }
249
250    #[test]
251    fn minimal_v1_value_decodes() {
252        let t = tags_with("kopiur-meta", r#"{"schema":1,"src":"inherited"}"#);
253        assert_eq!(
254            decode_meta_tag(&t),
255            MetaTagDecode::Decoded(meta(RecordedSrc::Inherited, None, None, None))
256        );
257    }
258
259    #[test]
260    fn unknown_extra_fields_are_accepted_forward_compat() {
261        let t = tags_with(
262            "kopiur-meta",
263            r#"{"schema":1,"src":"explicit","uid":0,"futureField":{"x":1}}"#,
264        );
265        assert_eq!(
266            decode_meta_tag(&t),
267            MetaTagDecode::Decoded(meta(RecordedSrc::Explicit, Some(0), None, None))
268        );
269    }
270
271    #[test]
272    fn unknown_src_string_decodes_to_unknown_not_malformed() {
273        let t = tags_with("kopiur-meta", r#"{"schema":1,"src":"workload","uid":7}"#);
274        assert_eq!(
275            decode_meta_tag(&t),
276            MetaTagDecode::Decoded(meta(RecordedSrc::Unknown, Some(7), None, None))
277        );
278    }
279
280    #[test]
281    fn newer_schema_degrades_to_unsupported() {
282        let t = tags_with(
283            "tag:kopiur-meta",
284            r#"{"schema":2,"src":"inherited","uid":0}"#,
285        );
286        assert_eq!(
287            decode_meta_tag(&t),
288            MetaTagDecode::UnsupportedSchema { schema: 2 }
289        );
290    }
291
292    #[test]
293    fn malformed_values_degrade_with_a_reason_never_an_error() {
294        for (value, why) in [
295            ("not json at all", "bad JSON"),
296            ("{}", "missing schema"),
297            (
298                r#"{"schema":"one","src":"inherited"}"#,
299                "non-integer schema",
300            ),
301            (r#"{"schema":1}"#, "missing src"),
302            (
303                r#"{"schema":1,"src":"inherited","uid":"root"}"#,
304                "bad uid type",
305            ),
306        ] {
307            let t = tags_with("kopiur-meta", value);
308            match decode_meta_tag(&t) {
309                MetaTagDecode::Malformed { reason } => {
310                    assert!(!reason.is_empty(), "{why}: reason must be actionable");
311                }
312                other => panic!("{why}: expected Malformed, got {other:?}"),
313            }
314        }
315    }
316
317    #[test]
318    fn src_serializes_lowercase_and_unknown_round_trips_as_a_string() {
319        assert_eq!(
320            serde_json::to_value(RecordedSrc::Inherited).unwrap(),
321            "inherited"
322        );
323        assert_eq!(
324            serde_json::to_value(RecordedSrc::Explicit).unwrap(),
325            "explicit"
326        );
327        assert_eq!(
328            serde_json::to_value(RecordedSrc::Defaults).unwrap(),
329            "defaults"
330        );
331        // A decoded-Unknown stored on status must survive the CRD schema, so it
332        // serializes as its own canonical string.
333        assert_eq!(
334            serde_json::to_value(RecordedSrc::Unknown).unwrap(),
335            "unknown"
336        );
337        let back: RecordedSrc = serde_json::from_value(serde_json::json!("unknown")).unwrap();
338        assert_eq!(back, RecordedSrc::Unknown);
339    }
340
341    #[test]
342    fn truncate_utf8_is_char_boundary_safe() {
343        assert_eq!(truncate_utf8("hello", 10), "hello");
344        assert_eq!(truncate_utf8("hello", 3), "hel");
345        // 'é' is 2 bytes; cutting mid-char backs off to the boundary.
346        let s = "aé"; // bytes: a=1, é=2 → len 3
347        assert_eq!(truncate_utf8(s, 2), "a");
348        assert_eq!(truncate_utf8(s, 3), "aé");
349        assert_eq!(truncate_utf8("日本語", 4), "日"); // 3-byte chars
350        assert_eq!(truncate_utf8("", 0), "");
351    }
352}