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}