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 29, 2021
1 parent 3657f8f commit d72c474
Show file tree
Hide file tree
Showing 3 changed files with 195 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
193 changes: 193 additions & 0 deletions lib/src/refescape.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
//! 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.
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)
}

/// Compute a string suitable for use as an OSTree ref, where `s` can be a (nearly)
/// arbitrary UTF-8 string. This requires a non-empty prefix.
///
/// The intention behind requiring a prefix is that a common need is to use e.g.
/// [`ostree::Repo::list_refs`] to find refs of a certain "type".
///
/// ```rust
/// # fn test() -> anyhow::Result<()> {
/// use ostree_ext::refescape;
/// assert_eq!(refescape::prefix_escape_for_ref("container", "registry:quay.io/coreos/fedora:latest")?, "container/registry_3A_quay_2E_io/coreos/fedora_3A_latest");
/// # Ok(())
/// }
/// ```
pub fn prefix_escape_for_ref(prefix: &str, s: &str) -> Result<String> {
Ok(format!("{}/{}", prefix, escape_for_ref(s)?))
}

/// Reverse the effect of [`escape_for_ref()`].
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)
}

/// Remove a prefix from an ostree ref, and return the unescaped remainder.
///
/// ```rust
/// # fn test() -> anyhow::Result<()> {
/// use ostree_ext::refescape;
/// assert_eq!(refescape::unprefix_unescape_ref("container", "container/registry_3A_quay_2E_io/coreos/fedora_3A_latest")?, "registry:quay.io/coreos/fedora:latest");
/// # Ok(())
/// }
/// ```
pub fn unprefix_unescape_ref(prefix: &str, ostree_ref: &str) -> Result<String> {
let rest = ostree_ref.strip_prefix(prefix).ok_or_else(|| {
anyhow::anyhow!(
"ref does not match expected prefix {}: {}",
ostree_ref,
prefix
)
})?;
Ok(unescape_for_ref(rest)?)
}

#[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 d72c474

Please sign in to comment.