Skip to main content

objstore_r/
key.rs

1// Author: spicyzhug@gmail.com
2// Copyright: lituus-io, all rights reserved
3//
4//! Object keys.
5//!
6//! One key grammar for every backend, so a store can move between a local
7//! directory and a bucket with a plain copy: `/`-separated segments of
8//! `[A-Za-z0-9._-]`, no empty segment, no segment starting with `.`, at most
9//! [`MAX_KEY`] bytes. The grammar also makes local paths safe by construction
10//! (no traversal, no absolute paths, no device names with separators).
11
12use crate::{Error, Result};
13
14/// Longest key, in bytes (GCS allows 1024).
15pub const MAX_KEY: usize = 1024;
16
17/// A validated key (borrowed).
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
19pub struct Key<'a>(&'a str);
20
21impl<'a> Key<'a> {
22    /// Validate a key.
23    ///
24    /// ```
25    /// use objstore_r::Key;
26    /// assert!(Key::parse("graph/base/0000000000000001.base").is_ok());
27    /// assert!(Key::parse("../etc/passwd").is_err());
28    /// assert!(Key::parse("a//b").is_err());
29    /// ```
30    pub fn parse(s: &'a str) -> Result<Self> {
31        check(s)?;
32        Ok(Key(s))
33    }
34
35    /// The key text.
36    #[must_use]
37    pub fn as_str(&self) -> &'a str {
38        self.0
39    }
40}
41
42/// Validate a list prefix: empty, or a key optionally ending in `/`.
43pub(crate) fn check_prefix(s: &str) -> Result<()> {
44    if s.is_empty() {
45        return Ok(());
46    }
47    check(s.strip_suffix('/').unwrap_or(s))
48}
49
50fn check(s: &str) -> Result<()> {
51    let bad = |reason| Err(Error::InvalidKey { reason });
52    if s.is_empty() {
53        return bad("empty");
54    }
55    if s.len() > MAX_KEY {
56        return bad("longer than 1024 bytes");
57    }
58    for seg in s.split('/') {
59        if seg.is_empty() {
60            return bad("empty segment");
61        }
62        if seg.starts_with('.') {
63            // Covers `.` and `..`, and reserves dot-names for store internals
64            // (temporary files, lock files) so they can never collide with keys.
65            return bad("segment starts with a dot");
66        }
67        if !seg.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) {
68            return bad("characters outside [A-Za-z0-9._-/]");
69        }
70        if seg.ends_with('.') {
71            return bad("segment ends with a dot");
72        }
73        if is_device_name(seg) {
74            return bad("reserved device name");
75        }
76    }
77    Ok(())
78}
79
80/// Windows device names (with any extension) cannot be files there; rejecting
81/// them everywhere keeps every key portable to every backend.
82fn is_device_name(seg: &str) -> bool {
83    let stem = seg.split('.').next().unwrap_or(seg);
84    let upper = stem.to_ascii_uppercase();
85    matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
86        || (upper.len() == 4
87            && (upper.starts_with("COM") || upper.starts_with("LPT"))
88            && upper.as_bytes()[3].is_ascii_digit())
89}
90
91/// Percent-encode `s` as one URL path or query component (RFC 3986
92/// unreserved kept; `/` encoded, as the GCS JSON API requires for object
93/// names), appending to `out`.
94pub fn encode_component(s: &str, out: &mut String) {
95    const HEX: &[u8; 16] = b"0123456789ABCDEF";
96    for b in s.bytes() {
97        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
98            out.push(char::from(b));
99        } else {
100            out.push('%');
101            out.push(char::from(HEX[usize::from(b >> 4)]));
102            out.push(char::from(HEX[usize::from(b & 0xF)]));
103        }
104    }
105}
106
107/// Fuzzing entry point: arbitrary input must be classified without panicking,
108/// and everything accepted must be a path that stays inside a root.
109#[doc(hidden)]
110pub fn fuzz(input: &[u8]) {
111    let Ok(s) = std::str::from_utf8(input) else { return };
112    let prefix_ok = check_prefix(s).is_ok();
113    if let Ok(k) = Key::parse(s) {
114        assert!(prefix_ok, "every key is a valid prefix");
115        let k = k.as_str();
116        assert!(!k.starts_with('/') && !k.ends_with('/') && !k.contains("//"));
117        assert!(k.split('/').all(|seg| seg != "." && seg != ".." && !seg.starts_with('.')));
118        assert!(k.bytes().all(|b| b.is_ascii_graphic() && b != b'\\'));
119        {
120            let mut enc = String::new();
121            encode_component(k, &mut enc);
122            assert!(enc.bytes().all(
123                |b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'%')
124            ));
125            assert!(!enc.contains('/'));
126        }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn grammar() {
136        for ok in [
137            "a",
138            "a/b",
139            "HEAD",
140            "bases/00000001.base",
141            "wal/0001/000000000001.seg",
142            "x-y_z.1",
143            "console",
144            "com",
145            "COMX",
146        ] {
147            assert!(Key::parse(ok).is_ok(), "{ok}");
148        }
149        for bad in [
150            "", "/a", "a/", "a//b", ".", "..", "a/../b", "a/./b", ".hidden", "a/.tmp", "a b",
151            "a\\b", "รค", "a\0b", "C:/x", "a?b", "a.", "x/NUL", "con.txt", "com1", "LPT9.log",
152        ] {
153            assert!(Key::parse(bad).is_err(), "{bad:?}");
154        }
155        assert!(Key::parse(&"a".repeat(MAX_KEY)).is_ok());
156        assert!(Key::parse(&"a".repeat(MAX_KEY + 1)).is_err());
157        assert!(
158            check_prefix("").is_ok()
159                && check_prefix("wal/").is_ok()
160                && check_prefix("wal/000").is_ok()
161        );
162        assert!(check_prefix("/").is_err() && check_prefix("../").is_err());
163    }
164
165    #[test]
166    fn encoding() {
167        let mut s = String::new();
168        encode_component("wal/0001/a-b_c.seg", &mut s);
169        assert_eq!(s, "wal%2F0001%2Fa-b_c.seg");
170    }
171}