Skip to content

Commit

Permalink
Add new refescape module
Browse files Browse the repository at this point in the history
Prep for work on the new container module, where we want to store
container image references (e.g. `docker://quay.io/coreos/fedora`)
as ostree refs.  Several bits of that are not valid in ostree refs,
such as the `:` or the double `//` (which would be an empty filesystem path).

This escaping scheme uses `_` in a similar way as a `\` character is
used in other syntax.  For example, `:` is `_3A_` (hexadecimal).
`//` is escaped as `/_2F_` (i.e. the second `/` is escaped).
  • Loading branch information
cgwalters committed Sep 28, 2021
1 parent 3657f8f commit c18bf9b
Show file tree
Hide file tree
Showing 3 changed files with 158 additions and 0 deletions.
1 change: 1 addition & 0 deletions lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ tracing = "0.1"
[dev-dependencies]
clap = "2.33.3"
indoc = "1.0.3"
quickcheck = "1"
sh-inline = "0.1.0"
structopt = "0.3.21"

Expand Down
1 change: 1 addition & 0 deletions lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub mod cli;
pub mod container;
pub mod diff;
pub mod ima;
pub mod refescape;
pub mod tar;
pub mod tokio_util;

Expand Down
156 changes: 156 additions & 0 deletions lib/src/refescape.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
//! Escape strings for use in ostree refs.
//!
//! It can be desirable to map arbitrary identifiers, such as RPM/dpkg
//! package names or container image references (e.g. `docker://quay.io/examplecorp/os:latest`)
//! into ostree refs (branch names) which have a quite restricted set
//! of valid characters; basically alphanumeric, plus `/`, `-`, `_`.
//!
//! This escaping scheme uses `_` in a similar way as a `\` character is
//! used in Rust unicode escaped values. For example, `:` is `_3A_` (hexadecimal).
//! Because the empty path is not valid, `//` is escaped as `/_2F_` (i.e. the second `/` is escaped).

use anyhow::Result;
use std::convert::TryInto;
use std::fmt::Write;

/// Escape a string in a suitable fashion for an ostree ref.
///
/// There are just a few restrictions:
/// - The empty string is not supported
/// - There may not be embedded `NUL` (`\\0`) characters.
pub fn escape_for_ref(s: &str) -> Result<String> {
if s.is_empty() {
return Err(anyhow::anyhow!("Invalid empty string for ref"));
}
fn escape_c(r: &mut String, c: char) {
write!(r, "_{:02X}_", c as u32).unwrap()
}
let mut r = String::new();
let mut it = s.chars().peekable();

// We require a leading alphanumeric
match it.peek() {
Some(&c) if !c.is_ascii_alphanumeric() => {
escape_c(&mut r, c);
let _ = it.next();
}
_ => {}
}

while let Some(c) = it.next() {
match (c, it.peek().copied()) {
('\0', _) => {
return Err(anyhow::anyhow!(
"Invalid embedded NUL in string for ostree ref"
));
}
(c, _) if c.is_ascii_alphanumeric() => {
r.push(c);
}
// The `/` must be followed by alphanumeric
('/', Some(next)) if !next.is_ascii_alphanumeric() => {
let _ = it.next();
r.push('/');
escape_c(&mut r, next);
}
// Also can't end on a `/` per above.
('/', None) => {
escape_c(&mut r, '/');
}
// Pass through - and `/` as valid
('-' | '/', _) => r.push(c),
// `_` becomes `__`.
('_', _) => r.push_str("__"),
// Otherwise, escape.
(o, _) => escape_c(&mut r, o),
}
}
Ok(r)
}

/// Reverse the effect of [`escape_for_ref()`].
pub fn unescape_for_ref(s: &str) -> Result<String> {
let mut r = String::new();
let mut it = s.chars();
let mut buf = String::new();
while let Some(c) = it.next() {
match c {
c if c.is_ascii_alphanumeric() => {
r.push(c);
}
'-' | '/' => r.push(c),
'_' => {
let next = it.next();
if let Some('_') = next {
r.push('_')
} else if let Some(c) = next {
buf.clear();
buf.push(c);
while let Some(c) = it.next() {
if c == '_' {
break;
}
buf.push(c);
}
let v = u32::from_str_radix(&buf, 16)?;
let c: char = v.try_into()?;
r.push(c);
}
}
o => anyhow::bail!("Invalid character {}", o),
}
}
Ok(r)
}

#[cfg(test)]
mod test {
use super::*;
use quickcheck::{quickcheck, TestResult};

const UNCHANGED: &[&str] = &["foo", "foo/bar/baz-blah/foo"];
const ROUNDTRIP: &[&str] = &[
"localhost:5000/foo:latest",
"fedora/x86_64/coreos",
"/foo/bar/foo.oci-archive",
"docker://quay.io/exampleos/blah:latest",
"oci-archive:/path/to/foo.ociarchive",
];

#[test]
fn escape() {
// These strings shouldn't change
for &v in UNCHANGED {
let escaped = &escape_for_ref(v).unwrap();
ostree::validate_rev(escaped).unwrap();
assert_eq!(escaped.as_str(), v);
}
// Roundtrip cases, plus unchanged cases
for &v in UNCHANGED.iter().chain(ROUNDTRIP) {
let escaped = &escape_for_ref(v).unwrap();
ostree::validate_rev(escaped).unwrap();
let unescaped = unescape_for_ref(&escaped).unwrap();
assert_eq!(v, unescaped);
}
// Explicit test
assert_eq!(
escape_for_ref(ROUNDTRIP[0]).unwrap(),
"localhost_3A_5000/foo_3A_latest"
);
}

fn roundtrip(s: String) -> TestResult {
// Ensure we only try strings which match the predicates.
let escaped = match escape_for_ref(&s) {
Ok(v) => v,
Err(_) => return TestResult::discard(),
};
let unescaped = unescape_for_ref(&escaped).unwrap();
TestResult::from_bool(unescaped == s)
}

#[test]
fn qcheck() {
quickcheck(roundtrip as fn(String) -> TestResult);
}
}

0 comments on commit c18bf9b

Please sign in to comment.