blob: 0e27c2398a1fc5ceea7185a0e0b9316dbfef01e0 [file] [log] [blame]
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use {
crate::errors::{RuleDecodeError, RuleParseError},
fidl_fuchsia_pkg_rewrite as fidl,
fuchsia_url::{AbsolutePackageUrl, ParseError, RepositoryUrl},
serde::{Deserialize, Serialize},
};
/// A `Rule` can be used to re-write parts of a [`AbsolutePackageUrl`].
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Rule {
host_match: RepositoryUrl,
host_replacement: RepositoryUrl,
path_prefix_match: String,
path_prefix_replacement: String,
}
/// Wraper for serializing rewrite rules to the on-disk JSON format.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(tag = "version", content = "content", deny_unknown_fields)]
pub enum RuleConfig {
#[allow(missing_docs)]
#[serde(rename = "1")]
Version1(Vec<Rule>),
}
impl Rule {
/// Creates a new `Rule`.
pub fn new(
host_match: impl Into<String>,
host_replacement: impl Into<String>,
path_prefix_match: impl Into<String>,
path_prefix_replacement: impl Into<String>,
) -> Result<Self, RuleParseError> {
Self::new_impl(
host_match.into(),
host_replacement.into(),
path_prefix_match.into(),
path_prefix_replacement.into(),
)
}
fn new_impl(
host_match: String,
host_replacement: String,
path_prefix_match: String,
path_prefix_replacement: String,
) -> Result<Self, RuleParseError> {
let host_match =
RepositoryUrl::parse_host(host_match).map_err(|_| RuleParseError::InvalidHost)?;
let host_replacement =
RepositoryUrl::parse_host(host_replacement).map_err(|_| RuleParseError::InvalidHost)?;
if !path_prefix_match.starts_with('/') {
return Err(RuleParseError::InvalidPath);
}
if !path_prefix_replacement.starts_with('/') {
return Err(RuleParseError::InvalidPath);
}
// Literal matches should have a literal replacement and prefix matches should have a
// prefix replacement.
if path_prefix_match.ends_with('/') != path_prefix_replacement.ends_with('/') {
return Err(RuleParseError::InconsistentPaths);
}
Ok(Self { host_match, host_replacement, path_prefix_match, path_prefix_replacement })
}
/// The exact hostname to match.
pub fn host_match(&self) -> &str {
self.host_match.host()
}
/// The new hostname to replace the matched `host_match` with.
pub fn host_replacement(&self) -> &str {
self.host_replacement.host()
}
/// The absolute path to a package or directory to match against.
pub fn path_prefix_match(&self) -> &str {
&self.path_prefix_match
}
/// The absolute path to a single package or a directory to replace the
/// matched `path_prefix_match` with.
pub fn path_prefix_replacement(&self) -> &str {
&self.path_prefix_replacement
}
/// Apply this `Rule` to the given [`AbsolutePackageUrl`].
///
/// In order for a `Rule` to match a particular fuchsia-pkg:// URI, `host` must match `uri`'s
/// host exactly and `path` must prefix match the `uri`'s path at a '/' boundary. If `path`
/// doesn't end in a '/', it must match the entire `uri` path.
///
/// When a `Rule` does match the given `uri`, it will replace the matched hostname and path
/// with the given replacement strings, preserving the unmatched part of the path, the hash
/// query parameter, and any fragment.
pub fn apply(
&self,
uri: &AbsolutePackageUrl,
) -> Option<Result<AbsolutePackageUrl, ParseError>> {
if uri.host() != self.host_match.host() {
return None;
}
let full_path = uri.path();
let new_path = if self.path_prefix_match.ends_with('/') {
let rest = full_path.strip_prefix(&self.path_prefix_match)?;
format!("{}{}", self.path_prefix_replacement, rest)
} else {
if full_path != self.path_prefix_match {
return None;
}
self.path_prefix_replacement.clone()
};
Some(AbsolutePackageUrl::new_with_path(
self.host_replacement.clone(),
&new_path,
uri.hash(),
))
}
}
impl TryFrom<fidl::Rule> for Rule {
type Error = RuleDecodeError;
fn try_from(rule: fidl::Rule) -> Result<Self, Self::Error> {
let rule = match rule {
fidl::Rule::Literal(rule) => rule,
_ => return Err(RuleDecodeError::UnknownVariant),
};
Ok(Rule::new(
rule.host_match,
rule.host_replacement,
rule.path_prefix_match,
rule.path_prefix_replacement,
)?)
}
}
impl From<Rule> for fidl::Rule {
fn from(rule: Rule) -> Self {
fidl::Rule::Literal(fidl::LiteralRule {
host_match: rule.host_match.into_host(),
host_replacement: rule.host_replacement.into_host(),
path_prefix_match: rule.path_prefix_match,
path_prefix_replacement: rule.path_prefix_replacement,
})
}
}
impl serde::Serialize for Rule {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
#[derive(Serialize)]
struct TempRule<'a> {
host_match: &'a str,
host_replacement: &'a str,
path_prefix_match: &'a str,
path_prefix_replacement: &'a str,
}
TempRule {
host_match: self.host_match(),
host_replacement: self.host_replacement(),
path_prefix_match: &self.path_prefix_match,
path_prefix_replacement: &self.path_prefix_replacement,
}
.serialize(serializer)
}
}
impl<'de> serde::Deserialize<'de> for Rule {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct TempRule {
host_match: String,
host_replacement: String,
path_prefix_match: String,
path_prefix_replacement: String,
}
let t = TempRule::deserialize(deserializer)?;
Rule::new(t.host_match, t.host_replacement, t.path_prefix_match, t.path_prefix_replacement)
.map_err(|e| serde::de::Error::custom(e.to_string()))
}
}
#[cfg(test)]
mod serde_tests {
use super::*;
use serde_json::json;
macro_rules! rule {
($host_match:expr => $host_replacement:expr,
$path_prefix_match:expr => $path_prefix_replacement:expr) => {
Rule::new($host_match, $host_replacement, $path_prefix_match, $path_prefix_replacement)
.unwrap()
};
}
macro_rules! assert_error_contains {
($err:expr, $text:expr,) => {
let error_message = $err.to_string();
assert!(
error_message.contains($text),
r#"error message did not contain "{}", was actually "{}""#,
$text,
error_message
);
};
}
#[test]
fn test_rejects_malformed_fidl() {
let as_fidl = fidl::Rule::Literal(fidl::LiteralRule {
host_match: "example.com".to_owned(),
host_replacement: "example.com".to_owned(),
path_prefix_match: "/test/".to_owned(),
path_prefix_replacement: "/test".to_owned(),
});
assert_eq!(
Rule::try_from(as_fidl),
Err(RuleDecodeError::ParseError(RuleParseError::InconsistentPaths))
);
let as_fidl = fidl::Rule::Literal(fidl::LiteralRule {
host_match: "example.com".to_owned(),
host_replacement: "example.com".to_owned(),
path_prefix_match: "/test".to_owned(),
path_prefix_replacement: "test".to_owned(),
});
assert_eq!(
Rule::try_from(as_fidl),
Err(RuleDecodeError::ParseError(RuleParseError::InvalidPath))
);
}
#[test]
fn test_rejects_unknown_fidl_variant() {
let as_fidl = fidl::Rule::unknown_variant_for_testing();
assert_eq!(Rule::try_from(as_fidl), Err(RuleDecodeError::UnknownVariant));
}
#[test]
fn test_rejects_unknown_json_version() {
let json = json!({
"version": "9001",
"content": "the future",
});
assert_error_contains!(
serde_json::from_str::<RuleConfig>(json.to_string().as_str()).unwrap_err(),
"unknown variant",
);
}
#[test]
fn test_rejects_malformed_json() {
let json = json!({
"version": "1",
"content": [{
"host_match": "example.com",
"host_replacement": "example.com",
"path_prefix_match": "/test/",
"path_prefix_replacement": "/test",
}]
});
assert_error_contains!(
serde_json::from_str::<Rule>(json["content"][0].to_string().as_str()).unwrap_err(),
"paths should both be a prefix match or both be a literal match",
);
assert_error_contains!(
serde_json::from_str::<RuleConfig>(json.to_string().as_str()).unwrap_err(),
"paths should both be a prefix match or both be a literal match",
);
let json = json!({
"version": "1",
"content": [{
"host_match": "example.com",
"host_replacement": "example.com",
"path_prefix_match": "test",
"path_prefix_replacement": "/test",
}]
});
assert_error_contains!(
serde_json::from_str::<Rule>(json["content"][0].to_string().as_str()).unwrap_err(),
"paths must start with",
);
assert_error_contains!(
serde_json::from_str::<RuleConfig>(json.to_string().as_str()).unwrap_err(),
"paths must start with",
);
}
#[test]
fn test_parse_all_foo_to_bar_rules() {
let json = json!({
"version": "1",
"content": [{
"host_match": "example.com",
"host_replacement": "example.com",
"path_prefix_match": "/foo",
"path_prefix_replacement": "/bar",
},{
"host_match": "example.com",
"host_replacement": "example.com",
"path_prefix_match": "/foo/",
"path_prefix_replacement": "/bar/",
}]
});
let expected = RuleConfig::Version1(vec![
rule!("example.com" => "example.com", "/foo" => "/bar"),
rule!("example.com" => "example.com", "/foo/" => "/bar/"),
]);
assert_eq!(
serde_json::from_str::<RuleConfig>(json.to_string().as_str()).unwrap(),
expected
);
assert_eq!(serde_json::to_value(expected).unwrap(), json);
}
}
#[cfg(test)]
mod rule_tests {
use super::*;
use assert_matches::assert_matches;
macro_rules! test_new_error {
(
$(
$test_name:ident => {
host = $host_match:expr => $host_replacement:expr,
path = $path_prefix_match:expr => $path_prefix_replacement:expr,
error = $error:expr,
}
)+
) => {
$(
#[test]
fn $test_name() {
let error = Rule::new(
$host_match,
$host_replacement,
$path_prefix_match,
$path_prefix_replacement,
)
.expect_err("should have failed to parse");
assert_eq!(error, $error);
let error = Rule::new(
$host_replacement,
$host_match,
$path_prefix_replacement,
$path_prefix_match,
)
.expect_err("should have failed to parse");
assert_eq!(error, $error);
}
)+
}
}
test_new_error! {
test_err_empty_host => {
host = "" => "example.com",
path = "/" => "/",
error = RuleParseError::InvalidHost,
}
test_err_invalid_host_match_uppercase => {
host = "EXAMPLE.ORG" => "example.com",
path = "/" => "/",
error = RuleParseError::InvalidHost,
}
test_err_invalid_host_replacement_uppercase => {
host = "example.org" => "EXAMPLE.COM",
path = "/" => "/",
error = RuleParseError::InvalidHost,
}
test_err_empty_path => {
host = "fuchsia.com" => "fuchsia.com",
path = "" => "rolldice",
error = RuleParseError::InvalidPath,
}
test_err_relative_path => {
host = "example.com" => "example.com",
path = "/rolldice" => "rolldice",
error = RuleParseError::InvalidPath,
}
test_err_inconsistent_match_type => {
host = "example.com" => "example.com",
path = "/rolldice/" => "/fortune",
error = RuleParseError::InconsistentPaths,
}
}
// Assumes apply creates a valid AbsolutePackageUrl if it matches
macro_rules! test_apply {
(
$(
$test_name:ident => {
host = $host_match:expr => $host_replacement:expr,
path = $path_prefix_match:expr => $path_prefix_replacement:expr,
cases = [ $(
$input:expr => $output:expr,
)+ ],
}
)+
) => {
$(
#[test]
fn $test_name() {
let rule = Rule::new(
$host_match.to_owned(),
$host_replacement.to_owned(),
$path_prefix_match.to_owned(),
$path_prefix_replacement.to_owned()
)
.unwrap();
$(
let input = AbsolutePackageUrl::parse($input).unwrap();
let output: Option<&str> = $output;
let output = output.map(|s| AbsolutePackageUrl::parse(s).unwrap());
assert_eq!(
rule.apply(&input).map(|res| res.unwrap()),
output,
"\n\nusing rule {:?}\nexpected {}\nto map to {},\nbut got {:?}\n\n",
rule,
$input,
stringify!($output),
rule.apply(&input).map(|x| x.map(|uri| uri.to_string())),
);
)+
}
)+
}
}
test_apply! {
test_nop => {
host = "fuchsia.com" => "fuchsia.com",
path = "/" => "/",
cases = [
"fuchsia-pkg://fuchsia.com/rolldice" => Some("fuchsia-pkg://fuchsia.com/rolldice"),
"fuchsia-pkg://fuchsia.com/rolldice/0" => Some("fuchsia-pkg://fuchsia.com/rolldice/0"),
"fuchsia-pkg://fuchsia.com/foo/0?hash=00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" => Some(
"fuchsia-pkg://fuchsia.com/foo/0?hash=00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"),
"fuchsia-pkg://example.com/rolldice" => None,
"fuchsia-pkg://example.com/rolldice/0" => None,
],
}
test_inject_subdomain => {
host = "fuchsia.com" => "test.fuchsia.com",
path = "/" => "/",
cases = [
"fuchsia-pkg://fuchsia.com/rolldice" => Some("fuchsia-pkg://test.fuchsia.com/rolldice"),
"fuchsia-pkg://fuchsia.com/rolldice/0" => Some("fuchsia-pkg://test.fuchsia.com/rolldice/0"),
"fuchsia-pkg://example.com/rolldice" => None,
"fuchsia-pkg://example.com/rolldice/0" => None,
],
}
test_inject_subdir => {
host = "fuchsia.com" => "fuchsia.com",
path = "/foo" => "/foo/bar",
cases = [
"fuchsia-pkg://fuchsia.com/foo" => Some("fuchsia-pkg://fuchsia.com/foo/bar"),
// TODO not supported until fuchsia-pkg URIs allow arbitrary package paths
//"fuchsia-pkg://fuchsia.com/foo/0" => Some("fuchsia-pkg://fuchsia.com/foo/bar/0")),
],
}
test_inject_parent_dir => {
host = "fuchsia.com" => "fuchsia.com",
path = "/foo" => "/bar/foo",
cases = [
"fuchsia-pkg://fuchsia.com/foo" => Some("fuchsia-pkg://fuchsia.com/bar/foo"),
],
}
test_replace_host => {
host = "fuchsia.com" => "example.com",
path = "/" => "/",
cases = [
"fuchsia-pkg://fuchsia.com/rolldice" => Some("fuchsia-pkg://example.com/rolldice"),
"fuchsia-pkg://fuchsia.com/rolldice/0" => Some("fuchsia-pkg://example.com/rolldice/0"),
"fuchsia-pkg://example.com/rolldice" => None,
"fuchsia-pkg://example.com/rolldice/0" => None,
],
}
test_replace_host_for_single_package => {
host = "fuchsia.com" => "example.com",
path = "/rolldice" => "/rolldice",
cases = [
"fuchsia-pkg://fuchsia.com/rolldice" => Some("fuchsia-pkg://example.com/rolldice"),
// this path pattern is a literal match
"fuchsia-pkg://fuchsia.com/rolldicer" => None,
// unrelated packages don't match
"fuchsia-pkg://fuchsia.com/fortune" => None,
],
}
test_replace_host_for_package_prefix => {
host = "fuchsia.com" => "example.com",
path = "/rolldice/" => "/rolldice/",
cases = [
"fuchsia-pkg://fuchsia.com/rolldice/0" => Some("fuchsia-pkg://example.com/rolldice/0"),
"fuchsia-pkg://fuchsia.com/rolldice/stable" => Some("fuchsia-pkg://example.com/rolldice/stable"),
// package with same name as directory doesn't match
"fuchsia-pkg://fuchsia.com/rolldice" => None,
],
}
test_rename_package => {
host = "fuchsia.com" => "fuchsia.com",
path = "/fake" => "/real",
cases = [
"fuchsia-pkg://fuchsia.com/fake" => Some("fuchsia-pkg://fuchsia.com/real"),
// not the same packages
"fuchsia-pkg://fuchsia.com/fakeout" => None,
],
}
test_rename_directory => {
host = "fuchsia.com" => "fuchsia.com",
path = "/fake/" => "/real/",
cases = [
"fuchsia-pkg://fuchsia.com/fake/0" => Some("fuchsia-pkg://fuchsia.com/real/0"),
"fuchsia-pkg://fuchsia.com/fake/package" => Some("fuchsia-pkg://fuchsia.com/real/package"),
// a package called "fake", not a directory.
"fuchsia-pkg://fuchsia.com/fake" => None,
],
}
}
#[test]
fn test_apply_creates_invalid_url() {
let rule = Rule::new("fuchsia.com", "fuchsia.com", "/", "/a+b/").unwrap();
assert_matches!(
rule.apply(&"fuchsia-pkg://fuchsia.com/foo".parse().unwrap()),
Some(Err(ParseError::InvalidName(_)))
);
}
}