ostree_ext/
refescape.rs

1//! Escape strings for use in ostree refs.
2//!
3//! It can be desirable to map arbitrary identifiers, such as RPM/dpkg
4//! package names or container image references (e.g. `docker://quay.io/examplecorp/os:latest`)
5//! into ostree refs (branch names) which have a quite restricted set
6//! of valid characters; basically alphanumeric, plus `/`, `-`, `_`.
7//!
8//! This escaping scheme uses `_` in a similar way as a `\` character is
9//! used in Rust unicode escaped values.  For example, `:` is `_3A_` (hexadecimal).
10//! Because the empty path is not valid, `//` is escaped as `/_2F_` (i.e. the second `/` is escaped).
11
12use anyhow::Result;
13use std::fmt::Write;
14
15/// Escape a single string; this is a backend of [`prefix_escape_for_ref`].
16fn escape_for_ref(s: &str) -> Result<String> {
17    if s.is_empty() {
18        return Err(anyhow::anyhow!("Invalid empty string for ref"));
19    }
20    fn escape_c(r: &mut String, c: char) {
21        write!(r, "_{:02X}_", c as u32).unwrap()
22    }
23    let mut r = String::new();
24    let mut it = s
25        .chars()
26        .map(|c| {
27            if c == '\0' {
28                Err(anyhow::anyhow!(
29                    "Invalid embedded NUL in string for ostree ref"
30                ))
31            } else {
32                Ok(c)
33            }
34        })
35        .peekable();
36
37    let mut previous_alphanumeric = false;
38    while let Some(c) = it.next() {
39        let has_next = it.peek().is_some();
40        let c = c?;
41        let current_alphanumeric = c.is_ascii_alphanumeric();
42        match c {
43            c if current_alphanumeric => r.push(c),
44            '/' if previous_alphanumeric && has_next => r.push(c),
45            // Pass through `-` unconditionally
46            '-' => r.push(c),
47            // The underscore `_` quotes itself `__`.
48            '_' => r.push_str("__"),
49            o => escape_c(&mut r, o),
50        }
51        previous_alphanumeric = current_alphanumeric;
52    }
53    Ok(r)
54}
55
56/// Compute a string suitable for use as an OSTree ref, where `s` can be a (nearly)
57/// arbitrary UTF-8 string.  This requires a non-empty prefix.
58///
59/// The restrictions on `s` are:
60/// - The empty string is not supported
61/// - There may not be embedded `NUL` (`\0`) characters.
62///
63/// The intention behind requiring a prefix is that a common need is to use e.g.
64/// [`ostree::Repo::list_refs`] to find refs of a certain "type".
65///
66/// # Examples:
67///
68/// ```rust
69/// # fn test() -> anyhow::Result<()> {
70/// use ostree_ext::refescape;
71/// let s = "registry:quay.io/coreos/fedora:latest";
72/// assert_eq!(refescape::prefix_escape_for_ref("container", s)?,
73///            "container/registry_3A_quay_2E_io/coreos/fedora_3A_latest");
74/// # Ok(())
75/// # }
76/// ```
77pub fn prefix_escape_for_ref(prefix: &str, s: &str) -> Result<String> {
78    Ok(format!("{}/{}", prefix, escape_for_ref(s)?))
79}
80
81/// Reverse the effect of [`escape_for_ref()`].
82fn unescape_for_ref(s: &str) -> Result<String> {
83    let mut r = String::new();
84    let mut it = s.chars();
85    let mut buf = String::new();
86    while let Some(c) = it.next() {
87        match c {
88            c if c.is_ascii_alphanumeric() => {
89                r.push(c);
90            }
91            '-' | '/' => r.push(c),
92            '_' => {
93                let next = it.next();
94                if let Some('_') = next {
95                    r.push('_')
96                } else if let Some(c) = next {
97                    buf.clear();
98                    buf.push(c);
99                    for c in &mut it {
100                        if c == '_' {
101                            break;
102                        }
103                        buf.push(c);
104                    }
105                    let v = u32::from_str_radix(&buf, 16)?;
106                    let c: char = v.try_into()?;
107                    r.push(c);
108                }
109            }
110            o => anyhow::bail!("Invalid character {}", o),
111        }
112    }
113    Ok(r)
114}
115
116/// Remove a prefix from an ostree ref, and return the unescaped remainder.
117///
118/// # Examples:
119///
120/// ```rust
121/// # fn test() -> anyhow::Result<()> {
122/// use ostree_ext::refescape;
123/// let s = "registry:quay.io/coreos/fedora:latest";
124/// assert_eq!(refescape::unprefix_unescape_ref("container", "container/registry_3A_quay_2E_io/coreos/fedora_3A_latest")?, s);
125/// # Ok(())
126/// # }
127/// ```
128pub fn unprefix_unescape_ref(prefix: &str, ostree_ref: &str) -> Result<String> {
129    let rest = ostree_ref
130        .strip_prefix(prefix)
131        .and_then(|s| s.strip_prefix('/'))
132        .ok_or_else(|| {
133            anyhow::anyhow!(
134                "ref does not match expected prefix {}/: {}",
135                ostree_ref,
136                prefix
137            )
138        })?;
139    unescape_for_ref(rest)
140}
141
142#[cfg(test)]
143mod test {
144    use super::*;
145    use quickcheck::{TestResult, quickcheck};
146
147    const TESTPREFIX: &str = "testprefix/blah";
148
149    const UNCHANGED: &[&str] = &["foo", "foo/bar/baz-blah/foo"];
150    const ROUNDTRIP: &[&str] = &[
151        "localhost:5000/foo:latest",
152        "fedora/x86_64/coreos",
153        "/foo/bar/foo.oci-archive",
154        "/foo/bar/foo.docker-archive",
155        "docker://quay.io/exampleos/blah:latest",
156        "oci-archive:/path/to/foo.ociarchive",
157        "docker-archive:/path/to/foo.dockerarchive",
158    ];
159    const CORNERCASES: &[&str] = &["/", "blah/", "/foo/"];
160
161    #[test]
162    fn escape() {
163        // These strings shouldn't change
164        for &v in UNCHANGED {
165            let escaped = &escape_for_ref(v).unwrap();
166            ostree::validate_rev(escaped).unwrap();
167            assert_eq!(escaped.as_str(), v);
168        }
169        // Roundtrip cases, plus unchanged cases
170        for &v in UNCHANGED.iter().chain(ROUNDTRIP).chain(CORNERCASES) {
171            let escaped = &prefix_escape_for_ref(TESTPREFIX, v).unwrap();
172            ostree::validate_rev(escaped).unwrap();
173            let unescaped = unprefix_unescape_ref(TESTPREFIX, escaped).unwrap();
174            assert_eq!(v, unescaped);
175        }
176        // Explicit test
177        assert_eq!(
178            escape_for_ref(ROUNDTRIP[0]).unwrap(),
179            "localhost_3A_5000/foo_3A_latest"
180        );
181    }
182
183    fn roundtrip(s: String) -> TestResult {
184        // Ensure we only try strings which match the predicates.
185        let r = prefix_escape_for_ref(TESTPREFIX, &s);
186        let escaped = match r {
187            Ok(v) => v,
188            Err(_) => return TestResult::discard(),
189        };
190        let unescaped = unprefix_unescape_ref(TESTPREFIX, &escaped).unwrap();
191        TestResult::from_bool(unescaped == s)
192    }
193
194    #[test]
195    fn qcheck() {
196        quickcheck(roundtrip as fn(String) -> TestResult);
197    }
198}