Skip to main content

s4_server/
tagging.rs

1//! Object + Bucket tagging (v0.6 #39).
2//!
3//! S3 attaches a `TagSet` (max 10 key/value pairs, key ≤ 128 bytes,
4//! value ≤ 256 bytes — AWS S3 spec) to each object, and another (also
5//! max 10) to each bucket. Tags are surfaced to the IAM policy
6//! evaluator via two condition keys:
7//!
8//! - `s3:ExistingObjectTag/<key>` — the existing tag attached to the
9//!   object the request is targeting (resolved via [`TagManager`] at
10//!   policy evaluation time).
11//! - `s3:RequestObjectTag/<key>` — a tag the caller is supplying as
12//!   part of *this* request, either via the `x-amz-tagging` URL-encoded
13//!   header on `PutObject`, or via the `Tagging` body field on
14//!   `PutObjectTagging`.
15//!
16//! ## scope (v0.6 #39)
17//!
18//! - **In-memory only** with optional JSON snapshot for restart-
19//!   recoverable state — same shape as `versioning.rs` /
20//!   `object_lock.rs`'s `--versioning-state-file` /
21//!   `--object-lock-state-file`.
22//! - **Per-(bucket, key) granularity**, no version-id-aware tag
23//!   attachment (matches the v0.5 #30 object-lock decision; AWS-style
24//!   per-version tags can be layered on top later).
25//! - **No charge / accounting** model — tags are stored, served, and
26//!   evaluated; cost-allocation reports are out of scope.
27//! - **No tag-key character validation** beyond the AWS length limits.
28//!   The wider AWS rule set (allowed character class, no `aws:` prefix
29//!   for user tags, etc.) is deferred — operators get the spec as it
30//!   relates to gating but can store any UTF-8 they like.
31//!
32//! ## scope-out (DO NOT touch — handled by sibling agents)
33//!
34//! - notification dispatch (#35), lifecycle expiration (#37)
35//! - ACL / replication / website / logging
36//! - per-version tag attachment
37
38use std::collections::HashMap;
39use std::sync::RwLock;
40
41use serde::{Deserialize, Serialize};
42
43/// AWS S3 max number of tags per object / bucket.
44pub const MAX_TAGS_PER_OBJECT: usize = 10;
45/// AWS S3 max length (in bytes) of a tag key.
46pub const MAX_TAG_KEY_BYTES: usize = 128;
47/// AWS S3 max length (in bytes) of a tag value.
48pub const MAX_TAG_VALUE_BYTES: usize = 256;
49
50/// An ordered tag set. Insertion order is preserved (mirrors the AWS
51/// XML wire format, which is order-significant for the response). For
52/// duplicates on the same key, the *last* pair wins on lookup, matching
53/// AWS S3 behaviour for `x-amz-tagging`.
54#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
55pub struct TagSet(pub Vec<(String, String)>);
56
57impl TagSet {
58    /// Empty tag set.
59    #[must_use]
60    pub fn new() -> Self {
61        Self::default()
62    }
63
64    /// Construct a tag set from `(key, value)` pairs, validating the
65    /// AWS S3 limits (max 10, key ≤ 128 B, value ≤ 256 B). Duplicate
66    /// keys are retained in insertion order; lookup picks the last one.
67    pub fn from_pairs(pairs: Vec<(String, String)>) -> Result<Self, TagError> {
68        let s = Self(pairs);
69        s.validate()?;
70        Ok(s)
71    }
72
73    /// Look up the value for `key`. Last-wins on duplicates.
74    #[must_use]
75    pub fn get(&self, key: &str) -> Option<&str> {
76        self.0
77            .iter()
78            .rev()
79            .find(|(k, _)| k == key)
80            .map(|(_, v)| v.as_str())
81    }
82
83    /// Iterate the pairs in insertion order.
84    pub fn iter(&self) -> impl Iterator<Item = &(String, String)> {
85        self.0.iter()
86    }
87
88    #[must_use]
89    pub fn len(&self) -> usize {
90        self.0.len()
91    }
92
93    #[must_use]
94    pub fn is_empty(&self) -> bool {
95        self.0.is_empty()
96    }
97
98    /// Enforce the AWS S3 spec: count ≤ 10, key ≤ 128 B, value ≤ 256 B,
99    /// keys must be non-empty, and keys must be unique within the set.
100    /// Called by [`Self::from_pairs`]; can also be called directly when
101    /// constructing a `TagSet` from external input that wasn't validated
102    /// yet.
103    ///
104    /// v0.8.4 #79: empty-key + duplicate-key rejection added so the
105    /// `x-amz-tagging` header path and the `PutObjectTagging` XML body
106    /// path both surface 400 InvalidArgument (matching AWS S3) instead
107    /// of silently last-wins-collapsing duplicates.
108    pub fn validate(&self) -> Result<(), TagError> {
109        if self.0.len() > MAX_TAGS_PER_OBJECT {
110            return Err(TagError::TooManyTags {
111                got: self.0.len(),
112                max: MAX_TAGS_PER_OBJECT,
113            });
114        }
115        let mut seen: std::collections::HashSet<&str> =
116            std::collections::HashSet::with_capacity(self.0.len());
117        for (k, v) in &self.0 {
118            if k.is_empty() {
119                return Err(TagError::EmptyKey);
120            }
121            if k.len() > MAX_TAG_KEY_BYTES {
122                return Err(TagError::KeyTooLong {
123                    len: k.len(),
124                    max: MAX_TAG_KEY_BYTES,
125                });
126            }
127            if v.len() > MAX_TAG_VALUE_BYTES {
128                return Err(TagError::ValueTooLong {
129                    key: k.clone(),
130                    len: v.len(),
131                    max: MAX_TAG_VALUE_BYTES,
132                });
133            }
134            if !seen.insert(k.as_str()) {
135                return Err(TagError::DuplicateKey { key: k.clone() });
136            }
137        }
138        Ok(())
139    }
140}
141
142/// Error class for tag-set construction / parse.
143///
144/// v0.8.4 #79: `EmptyKey` / `DuplicateKey` added, and the size-bound
145/// variants now carry the configured `max` (and the offending key for
146/// `ValueTooLong`) so the error surface is self-describing on the wire.
147#[derive(Debug, thiserror::Error)]
148pub enum TagError {
149    #[error("too many tags: {got} (max {max} per object/bucket)")]
150    TooManyTags { got: usize, max: usize },
151    #[error("tag key must not be empty")]
152    EmptyKey,
153    #[error("tag key too long: {len} bytes (max {max})")]
154    KeyTooLong { len: usize, max: usize },
155    #[error("tag value too long: key {key:?} value is {len} bytes (max {max})")]
156    ValueTooLong { key: String, len: usize, max: usize },
157    #[error("duplicate tag key: {key:?}")]
158    DuplicateKey { key: String },
159    #[error("invalid tag header (URL-encoded): {0}")]
160    InvalidHeader(String),
161}
162
163/// JSON snapshot wrapper. Tuple keys can't roundtrip through
164/// `HashMap` JSON, so the object map is flattened to a `Vec`.
165#[derive(Debug, Default, Serialize, Deserialize)]
166struct TagSnapshot {
167    objects: Vec<((String, String), TagSet)>,
168    buckets: HashMap<String, TagSet>,
169}
170
171/// Owns the per-(bucket, key) and per-bucket tag state. All operations
172/// take the inner `RwLock`; cloning a manager is intentionally not
173/// supported — share via `Arc<TagManager>`.
174#[derive(Debug, Default)]
175pub struct TagManager {
176    /// `(bucket, key) → tags`
177    objects: RwLock<HashMap<(String, String), TagSet>>,
178    /// `bucket → tags`
179    buckets: RwLock<HashMap<String, TagSet>>,
180}
181
182impl TagManager {
183    /// Empty manager.
184    #[must_use]
185    pub fn new() -> Self {
186        Self::default()
187    }
188
189    /// Replace (or create) the object-level tag set. AWS PutObjectTagging
190    /// is a full-replace operation (no merge), so we mirror that.
191    pub fn put_object_tags(&self, bucket: &str, key: &str, tags: TagSet) {
192        crate::lock_recovery::recover_write(&self.objects, "tagging.objects")
193            .insert((bucket.to_owned(), key.to_owned()), tags);
194    }
195
196    /// Borrow-clone the object-level tag set. `None` when no tags have
197    /// been set for `(bucket, key)`.
198    #[must_use]
199    pub fn get_object_tags(&self, bucket: &str, key: &str) -> Option<TagSet> {
200        crate::lock_recovery::recover_read(&self.objects, "tagging.objects")
201            .get(&(bucket.to_owned(), key.to_owned()))
202            .cloned()
203    }
204
205    /// Drop the object-level tag set for `(bucket, key)` (idempotent —
206    /// missing entry is a no-op, matching AWS DeleteObjectTagging).
207    pub fn delete_object_tags(&self, bucket: &str, key: &str) {
208        crate::lock_recovery::recover_write(&self.objects, "tagging.objects")
209            .remove(&(bucket.to_owned(), key.to_owned()));
210    }
211
212    /// Replace (or create) the bucket-level tag set.
213    pub fn put_bucket_tags(&self, bucket: &str, tags: TagSet) {
214        crate::lock_recovery::recover_write(&self.buckets, "tagging.buckets")
215            .insert(bucket.to_owned(), tags);
216    }
217
218    /// Borrow-clone the bucket-level tag set.
219    #[must_use]
220    pub fn get_bucket_tags(&self, bucket: &str) -> Option<TagSet> {
221        crate::lock_recovery::recover_read(&self.buckets, "tagging.buckets")
222            .get(bucket)
223            .cloned()
224    }
225
226    /// Drop the bucket-level tag set (idempotent).
227    pub fn delete_bucket_tags(&self, bucket: &str) {
228        crate::lock_recovery::recover_write(&self.buckets, "tagging.buckets").remove(bucket);
229    }
230
231    /// JSON snapshot for restart-recoverable state. Pair with
232    /// [`Self::from_json`].
233    pub fn to_json(&self) -> Result<String, serde_json::Error> {
234        let objects: Vec<((String, String), TagSet)> =
235            crate::lock_recovery::recover_read(&self.objects, "tagging.objects")
236                .iter()
237                .map(|(k, v)| (k.clone(), v.clone()))
238                .collect();
239        let buckets = crate::lock_recovery::recover_read(&self.buckets, "tagging.buckets").clone();
240        let snap = TagSnapshot { objects, buckets };
241        serde_json::to_string(&snap)
242    }
243
244    /// Restore from a JSON snapshot produced by [`Self::to_json`].
245    pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
246        let snap: TagSnapshot = serde_json::from_str(s)?;
247        let mut objects = HashMap::with_capacity(snap.objects.len());
248        for (k, v) in snap.objects {
249            objects.insert(k, v);
250        }
251        Ok(Self {
252            objects: RwLock::new(objects),
253            buckets: RwLock::new(snap.buckets),
254        })
255    }
256}
257
258/// Parse the AWS S3 `x-amz-tagging` request header. The wire format is
259/// a URL-encoded query string (`Project=Phoenix&Env=prod`); each pair
260/// is `key=value` with both halves percent-decoded. An empty header
261/// resolves to an empty `TagSet`. Keys without `=` are treated as
262/// `(key, "")` (matches `serde_urlencoded` / browser form-encode).
263///
264/// v0.8.4 #79: the parsed result is validated against the full AWS S3
265/// spec — count ≤ 10, key non-empty + ≤ 128 B, value ≤ 256 B, keys
266/// unique within the set. Any violation is returned as a `TagError`
267/// variant (the caller maps these to 400 `InvalidArgument`).
268pub fn parse_tagging_header(header: &str) -> Result<TagSet, TagError> {
269    let trimmed = header.trim();
270    if trimmed.is_empty() {
271        return Ok(TagSet::new());
272    }
273    let mut pairs = Vec::new();
274    for part in trimmed.split('&') {
275        if part.is_empty() {
276            continue;
277        }
278        let (raw_k, raw_v) = match part.split_once('=') {
279            Some((k, v)) => (k, v),
280            None => (part, ""),
281        };
282        let k = url_decode(raw_k)
283            .map_err(|e| TagError::InvalidHeader(format!("key {raw_k:?}: {e}")))?;
284        let v = url_decode(raw_v)
285            .map_err(|e| TagError::InvalidHeader(format!("value {raw_v:?}: {e}")))?;
286        pairs.push((k, v));
287    }
288    TagSet::from_pairs(pairs)
289}
290
291/// Render a tag set as an AWS S3 `x-amz-tagging` URL-encoded string,
292/// suitable for the response echo header. Insertion order is
293/// preserved.
294#[must_use]
295pub fn render_tagging_header(tags: &TagSet) -> String {
296    let mut out = String::new();
297    for (i, (k, v)) in tags.iter().enumerate() {
298        if i > 0 {
299            out.push('&');
300        }
301        url_encode_to(&mut out, k);
302        out.push('=');
303        url_encode_to(&mut out, v);
304    }
305    out
306}
307
308/// Minimal `application/x-www-form-urlencoded` decoder: turns `+` into
309/// space (RFC 3986 form variant — AWS S3 accepts both `%20` and `+`)
310/// and resolves `%xx` escapes to their byte value. Returns an error
311/// when a `%` is not followed by two hex digits, or when the resulting
312/// bytes are not valid UTF-8.
313fn url_decode(s: &str) -> Result<String, String> {
314    let bytes = s.as_bytes();
315    let mut out = Vec::with_capacity(bytes.len());
316    let mut i = 0;
317    while i < bytes.len() {
318        match bytes[i] {
319            b'+' => {
320                out.push(b' ');
321                i += 1;
322            }
323            b'%' => {
324                if i + 2 >= bytes.len() {
325                    return Err(format!("truncated %-escape at byte {i}"));
326                }
327                let hi = hex_digit(bytes[i + 1])
328                    .ok_or_else(|| format!("non-hex byte after % at {}", i + 1))?;
329                let lo = hex_digit(bytes[i + 2])
330                    .ok_or_else(|| format!("non-hex byte after % at {}", i + 2))?;
331                out.push((hi << 4) | lo);
332                i += 3;
333            }
334            b => {
335                out.push(b);
336                i += 1;
337            }
338        }
339    }
340    String::from_utf8(out).map_err(|e| format!("invalid UTF-8: {e}"))
341}
342
343fn hex_digit(b: u8) -> Option<u8> {
344    match b {
345        b'0'..=b'9' => Some(b - b'0'),
346        b'a'..=b'f' => Some(10 + b - b'a'),
347        b'A'..=b'F' => Some(10 + b - b'A'),
348        _ => None,
349    }
350}
351
352/// Append `s` to `out`, percent-encoding everything that isn't an
353/// unreserved RFC 3986 character (`A-Za-z0-9-_.~`). Conservative —
354/// AWS accepts a wider class but never *requires* it, so we keep the
355/// output portable.
356fn url_encode_to(out: &mut String, s: &str) {
357    for &b in s.as_bytes() {
358        let unreserved =
359            b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b == b'~';
360        if unreserved {
361            out.push(b as char);
362        } else {
363            out.push('%');
364            out.push(HEX[((b >> 4) & 0x0F) as usize] as char);
365            out.push(HEX[(b & 0x0F) as usize] as char);
366        }
367    }
368}
369
370const HEX: &[u8; 16] = b"0123456789ABCDEF";
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn from_pairs_too_many_rejected() {
378        // Use distinct keys so the duplicate-key check (also enforced
379        // by validate) doesn't fire first — we want to assert the
380        // count guard specifically.
381        let pairs: Vec<(String, String)> = (0..11)
382            .map(|i| (format!("k{i}"), format!("v{i}")))
383            .collect();
384        let err = TagSet::from_pairs(pairs).expect_err("must reject 11 pairs");
385        assert!(
386            matches!(
387                err,
388                TagError::TooManyTags {
389                    got: 11,
390                    max: MAX_TAGS_PER_OBJECT
391                }
392            ),
393            "got: {err:?}"
394        );
395    }
396
397    #[test]
398    fn from_pairs_long_key_rejected() {
399        let pairs = vec![("k".repeat(129), "v".into())];
400        let err = TagSet::from_pairs(pairs).expect_err("must reject 129-byte key");
401        assert!(
402            matches!(
403                err,
404                TagError::KeyTooLong {
405                    len: 129,
406                    max: MAX_TAG_KEY_BYTES
407                }
408            ),
409            "got: {err:?}"
410        );
411    }
412
413    #[test]
414    fn from_pairs_long_value_rejected() {
415        let pairs = vec![("k".into(), "v".repeat(257))];
416        let err = TagSet::from_pairs(pairs).expect_err("must reject 257-byte value");
417        assert!(
418            matches!(
419                err,
420                TagError::ValueTooLong { ref key, len: 257, max: MAX_TAG_VALUE_BYTES }
421                    if key == "k"
422            ),
423            "got: {err:?}"
424        );
425    }
426
427    #[test]
428    fn from_pairs_at_limits_accepted() {
429        // Exactly 10 tags, key = 128 bytes, value = 256 bytes — at the
430        // boundary, must be accepted.
431        let pairs: Vec<(String, String)> = (0..10)
432            .map(|i| {
433                let k = format!("k{i}");
434                let v = format!("v{i}");
435                let k = format!("{k:k<128}");
436                let v = format!("{v:v<256}");
437                (k, v)
438            })
439            .collect();
440        // Sanity: lengths are at the limit.
441        for (k, v) in &pairs {
442            assert_eq!(k.len(), 128);
443            assert_eq!(v.len(), 256);
444        }
445        let s = TagSet::from_pairs(pairs).expect("at-limit pairs must pass");
446        assert_eq!(s.len(), 10);
447    }
448
449    #[test]
450    fn parse_tagging_header_basic() {
451        let s = parse_tagging_header("K1=V1&K2=V2").expect("parse");
452        assert_eq!(s.len(), 2);
453        assert_eq!(s.get("K1"), Some("V1"));
454        assert_eq!(s.get("K2"), Some("V2"));
455    }
456
457    #[test]
458    fn parse_tagging_header_url_encoded_values() {
459        // `%20` (space), `%2F` (slash), and `+` (form-style space).
460        let s = parse_tagging_header("Path=foo%2Fbar&Greet=hello%20world&Plus=a+b").expect("parse");
461        assert_eq!(s.get("Path"), Some("foo/bar"));
462        assert_eq!(s.get("Greet"), Some("hello world"));
463        assert_eq!(s.get("Plus"), Some("a b"));
464    }
465
466    #[test]
467    fn parse_tagging_header_empty_value() {
468        let s = parse_tagging_header("Bare").expect("parse");
469        assert_eq!(s.get("Bare"), Some(""));
470        let s2 = parse_tagging_header("K=").expect("parse");
471        assert_eq!(s2.get("K"), Some(""));
472    }
473
474    #[test]
475    fn parse_tagging_header_empty_returns_empty_set() {
476        let s = parse_tagging_header("").expect("parse");
477        assert!(s.is_empty());
478        let s2 = parse_tagging_header("   ").expect("parse");
479        assert!(s2.is_empty());
480    }
481
482    #[test]
483    fn parse_tagging_header_truncated_escape_rejected() {
484        let err = parse_tagging_header("K=%2").expect_err("truncated");
485        assert!(matches!(err, TagError::InvalidHeader(_)));
486    }
487
488    #[test]
489    fn render_tagging_header_round_trip() {
490        let original = TagSet::from_pairs(vec![
491            ("Project".into(), "Phoenix".into()),
492            ("Env".into(), "prod with space".into()),
493            ("Path".into(), "data/2026".into()),
494        ])
495        .expect("ts");
496        let rendered = render_tagging_header(&original);
497        let parsed = parse_tagging_header(&rendered).expect("parse");
498        assert_eq!(parsed, original);
499    }
500
501    #[test]
502    fn manager_object_put_get_delete() {
503        let m = TagManager::new();
504        let tags = TagSet::from_pairs(vec![("Owner".into(), "alice".into())]).expect("ts");
505        m.put_object_tags("b", "k", tags.clone());
506        assert_eq!(m.get_object_tags("b", "k"), Some(tags));
507        m.delete_object_tags("b", "k");
508        assert!(m.get_object_tags("b", "k").is_none());
509        // Idempotent re-delete.
510        m.delete_object_tags("b", "k");
511    }
512
513    #[test]
514    fn manager_bucket_put_get_delete() {
515        let m = TagManager::new();
516        let tags = TagSet::from_pairs(vec![("CostCenter".into(), "42".into())]).expect("ts");
517        m.put_bucket_tags("b", tags.clone());
518        assert_eq!(m.get_bucket_tags("b"), Some(tags));
519        m.delete_bucket_tags("b");
520        assert!(m.get_bucket_tags("b").is_none());
521    }
522
523    #[test]
524    fn manager_object_and_bucket_independent() {
525        // Setting an object tag must not pollute the bucket-level map
526        // (and vice versa). Regression guard for an early-prototype
527        // bug where both maps were keyed by `bucket` only.
528        let m = TagManager::new();
529        m.put_object_tags(
530            "b",
531            "k",
532            TagSet::from_pairs(vec![("o".into(), "1".into())]).unwrap(),
533        );
534        m.put_bucket_tags(
535            "b",
536            TagSet::from_pairs(vec![("b".into(), "2".into())]).unwrap(),
537        );
538        assert_eq!(m.get_object_tags("b", "k").unwrap().get("o"), Some("1"));
539        assert!(m.get_object_tags("b", "k").unwrap().get("b").is_none());
540        assert_eq!(m.get_bucket_tags("b").unwrap().get("b"), Some("2"));
541        assert!(m.get_bucket_tags("b").unwrap().get("o").is_none());
542    }
543
544    #[test]
545    fn manager_json_snapshot_round_trip() {
546        let m = TagManager::new();
547        m.put_object_tags(
548            "b1",
549            "k1",
550            TagSet::from_pairs(vec![("Project".into(), "Phoenix".into())]).unwrap(),
551        );
552        m.put_object_tags(
553            "b2",
554            "k2",
555            TagSet::from_pairs(vec![("Env".into(), "prod".into())]).unwrap(),
556        );
557        m.put_bucket_tags(
558            "b1",
559            TagSet::from_pairs(vec![("CostCenter".into(), "42".into())]).unwrap(),
560        );
561        let json = m.to_json().expect("to_json");
562        let m2 = TagManager::from_json(&json).expect("from_json");
563        assert_eq!(
564            m2.get_object_tags("b1", "k1").unwrap().get("Project"),
565            Some("Phoenix")
566        );
567        assert_eq!(
568            m2.get_object_tags("b2", "k2").unwrap().get("Env"),
569            Some("prod")
570        );
571        assert_eq!(
572            m2.get_bucket_tags("b1").unwrap().get("CostCenter"),
573            Some("42")
574        );
575    }
576
577    #[test]
578    fn tag_set_get_last_wins_on_duplicate_keys() {
579        // The `get` lookup is documented as last-wins on duplicates so
580        // that callers who pre-populate a `TagSet` from an unvalidated
581        // source still observe a deterministic value. Post-v0.8.4 #79
582        // the public `parse_tagging_header` / `from_pairs` paths reject
583        // duplicates outright, so we construct the dup-bearing set
584        // directly to exercise the lookup contract.
585        let s = TagSet(vec![("K".into(), "A".into()), ("K".into(), "B".into())]);
586        assert_eq!(s.get("K"), Some("B"));
587    }
588
589    // --- v0.8.4 #79: AWS S3 spec validation on the header parse path ---
590
591    #[test]
592    fn parse_tagging_header_empty_key_rejected() {
593        // `=value` decodes to (key="", value="value"); AWS S3 returns
594        // 400 InvalidArgument. Pre-#79 we accepted and stored the empty
595        // key.
596        let err = parse_tagging_header("=value").expect_err("empty key");
597        assert!(matches!(err, TagError::EmptyKey), "got: {err:?}");
598    }
599
600    #[test]
601    fn parse_tagging_header_long_key_rejected() {
602        // 129-byte key (one over the 128-byte limit) → KeyTooLong.
603        let header = format!("{}=v", "k".repeat(129));
604        let err = parse_tagging_header(&header).expect_err("129-byte key");
605        assert!(
606            matches!(
607                err,
608                TagError::KeyTooLong {
609                    len: 129,
610                    max: MAX_TAG_KEY_BYTES
611                }
612            ),
613            "got: {err:?}"
614        );
615    }
616
617    #[test]
618    fn parse_tagging_header_long_value_rejected() {
619        // 257-byte value (one over the 256-byte limit) → ValueTooLong,
620        // and the variant carries the offending key for diagnostics.
621        let header = format!("k={}", "v".repeat(257));
622        let err = parse_tagging_header(&header).expect_err("257-byte value");
623        assert!(
624            matches!(
625                err,
626                TagError::ValueTooLong { ref key, len: 257, max: MAX_TAG_VALUE_BYTES }
627                    if key == "k"
628            ),
629            "got: {err:?}"
630        );
631    }
632
633    #[test]
634    fn parse_tagging_header_duplicate_key_rejected() {
635        // `K=A&K=B` — AWS S3 returns 400 InvalidArgument; pre-#79 S4
636        // collapsed silently to the last value (B).
637        let err = parse_tagging_header("K=A&K=B").expect_err("dup key");
638        assert!(
639            matches!(err, TagError::DuplicateKey { ref key } if key == "K"),
640            "got: {err:?}"
641        );
642    }
643
644    #[test]
645    fn parse_tagging_header_too_many_tags_rejected() {
646        // 11 unique tags (one over the 10-per-object cap) → TooManyTags.
647        let header: String = (0..11)
648            .map(|i| format!("k{i}=v{i}"))
649            .collect::<Vec<_>>()
650            .join("&");
651        let err = parse_tagging_header(&header).expect_err("11 tags");
652        assert!(
653            matches!(
654                err,
655                TagError::TooManyTags {
656                    got: 11,
657                    max: MAX_TAGS_PER_OBJECT
658                }
659            ),
660            "got: {err:?}"
661        );
662    }
663
664    /// v0.8.4 #77 (audit H-8): a panic inside the `objects` write guard
665    /// poisons the lock. `to_json` must recover via
666    /// [`crate::lock_recovery::recover_read`] and surface the data
667    /// instead of re-panicking.
668    #[test]
669    fn tagging_to_json_after_panic_recovers_via_poison() {
670        let m = TagManager::new();
671        m.put_object_tags(
672            "b",
673            "k",
674            TagSet::from_pairs(vec![("Project".into(), "Phoenix".into())]).expect("valid"),
675        );
676        let m = std::sync::Arc::new(m);
677        let m_cl = std::sync::Arc::clone(&m);
678        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
679            let mut g = m_cl.objects.write().expect("clean lock");
680            g.entry(("b".into(), "k2".into())).or_default();
681            panic!("force-poison");
682        }));
683        assert!(
684            m.objects.is_poisoned(),
685            "write panic must poison objects lock"
686        );
687        let json = m.to_json().expect("to_json after poison must succeed");
688        let m2 = TagManager::from_json(&json).expect("from_json");
689        assert!(m2.get_object_tags("b", "k").is_some());
690    }
691}