#[derive(Debug, Clone, Default, PartialEq)]
pub struct PrefixPolicy {
local: Vec<String>,
local_except: Vec<String>,
local_only: Vec<String>,
local_only_except: Vec<String>,
zone: Vec<String>,
readonly: Vec<String>,
}
pub fn is_remote(remote: Option<std::net::SocketAddr>) -> bool {
remote.is_some_and(|a| !a.ip().is_loopback())
}
fn prefix_end(p: &[u8]) -> Option<Vec<u8>> {
let mut e = p.to_vec();
while let Some(&last) = e.last() {
if last == 0xff {
e.pop();
} else {
*e.last_mut().unwrap() += 1;
return Some(e);
}
}
None
}
fn range_overlaps(key: &[u8], end: &[u8], p: &[u8]) -> bool {
if end.is_empty() {
return key.starts_with(p);
}
let below_hi = match prefix_end(p) {
Some(hi) => key < hi.as_slice(),
None => true,
};
let above_lo = end == [0u8] || end > p;
below_hi && above_lo
}
fn range_within(key: &[u8], end: &[u8], p: &[u8]) -> bool {
if !key.starts_with(p) {
return false;
}
if end.is_empty() {
return true;
}
match prefix_end(p) {
Some(hi) => end != [0u8] && end <= hi.as_slice(),
None => true,
}
}
fn split(spec: &str) -> Vec<String> {
spec.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
impl PrefixPolicy {
pub fn new(local: &str, local_except: &str, local_only: &str, zone: &str) -> Self {
PrefixPolicy {
local: split(local),
local_except: split(local_except),
local_only: split(local_only),
zone: split(zone),
..Default::default()
}
}
pub fn with_local_only_except(mut self, spec: &str) -> Self {
self.local_only_except = split(spec);
self
}
pub fn with_readonly(mut self, spec: &str) -> Self {
self.readonly = split(spec);
self
}
pub fn is_zone_scoped(&self, key: &[u8]) -> bool {
if self.zone.is_empty() {
return false;
}
let k = String::from_utf8_lossy(key);
self.zone.iter().any(|p| k.starts_with(p.as_str()))
}
pub fn propagates_to_zone(&self, key: &[u8], my_zone: &str, peer_zone: &str) -> bool {
if !self.propagates(key) {
return false;
}
if !self.is_zone_scoped(key) {
return true;
}
my_zone.trim() == peer_zone.trim()
}
pub fn is_empty(&self) -> bool {
self.local.is_empty() && self.local_only.is_empty() && self.zone.is_empty() && self.readonly.is_empty()
}
pub fn propagates(&self, key: &[u8]) -> bool {
if self.local.is_empty() {
return true;
}
let k = String::from_utf8_lossy(key);
if !self.local.iter().any(|p| k.starts_with(p.as_str())) {
return true;
}
self.local_except.iter().any(|p| k.starts_with(p.as_str()))
}
pub fn may_serve(&self, key: &[u8], remote: Option<std::net::SocketAddr>) -> bool {
if self.local_only.is_empty() {
return true;
}
let k = String::from_utf8_lossy(key);
if !self.local_only.iter().any(|p| k.starts_with(p.as_str())) {
return true;
}
if self.local_only_except.iter().any(|p| k.starts_with(p.as_str())) {
return true;
}
!is_remote(remote)
}
pub fn range_may_serve(&self, key: &[u8], end: &[u8], remote: Option<std::net::SocketAddr>) -> bool {
if self.local_only.is_empty() || !is_remote(remote) {
return true;
}
if !self.local_only.iter().any(|p| range_overlaps(key, end, p.as_bytes())) {
return true;
}
self.local_only_except.iter().any(|e| range_within(key, end, e.as_bytes()))
}
pub fn writable(&self, key: &[u8]) -> bool {
!self.readonly.iter().any(|p| key.starts_with(p.as_bytes()))
}
pub fn range_writable(&self, key: &[u8], end: &[u8]) -> bool {
!self.readonly.iter().any(|p| range_overlaps(key, end, p.as_bytes()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn skv() -> PrefixPolicy {
PrefixPolicy::new(
"/secret/",
"/secret/encrypted/,/secret/inquiry/,/secret/@meta/",
"/secret/",
"/secret/",
)
}
#[test]
fn an_unconfigured_policy_changes_nothing() {
let p = PrefixPolicy::default();
assert!(p.is_empty());
assert!(p.propagates(b"/secret/db_pw"));
assert!(p.may_serve(b"/secret/db_pw", "10.0.0.7:1".parse().ok()));
}
#[test]
fn the_subtree_is_local_and_the_exceptions_travel() {
let p = skv();
assert!(!p.propagates(b"/secret/db_pw"), "the bare namespace is local");
assert!(!p.propagates(b"/secret/share/db_pw"), "share is a local drop point");
assert!(p.propagates(b"/secret/encrypted/db_pw"));
assert!(p.propagates(b"/secret/inquiry/db_pw"));
assert!(p.propagates(b"/secret/@meta/keyver"));
}
#[test]
fn everything_outside_the_subtree_is_untouched() {
let p = skv();
assert!(p.propagates(b"/q/ch:orders/1"));
assert!(p.propagates(b"/cluster/peers/abc"));
assert!(p.may_serve(b"/q/ch:orders/1", "10.0.0.7:1".parse().ok()));
assert!(p.may_serve(b"/cluster/peers/abc", "10.0.0.7:1".parse().ok()));
}
#[test]
fn a_new_branch_fails_safe() {
let p = skv();
assert!(!p.propagates(b"/secret/newthing/db_pw"));
assert!(!p.propagates(b"/secret/encrypt/db_pw"));
}
#[test]
fn the_subtree_is_refused_over_the_network() {
let p = skv();
let far: std::net::SocketAddr = "10.0.0.7:2379".parse().unwrap();
for k in [&b"/secret/db_pw"[..], b"/secret/encrypted/db_pw", b"/secret/@meta/keyver"] {
assert!(!p.may_serve(k, Some(far)), "{} served remotely", String::from_utf8_lossy(k));
}
for lo in ["127.0.0.1:2379", "[::1]:2379"] {
assert!(p.may_serve(b"/secret/db_pw", lo.parse().ok()));
}
assert!(p.may_serve(b"/secret/db_pw", None));
}
#[test]
fn the_two_policies_are_independent() {
let p = skv();
assert!(p.propagates(b"/secret/encrypted/db_pw"));
assert!(!p.may_serve(b"/secret/encrypted/db_pw", "10.0.0.7:1".parse().ok()));
}
#[test]
fn a_zone_scoped_key_stays_in_its_zone() {
let p = skv();
assert!(p.propagates_to_zone(b"/secret/encrypted/db_pw", "east", "east"));
assert!(!p.propagates_to_zone(b"/secret/encrypted/db_pw", "east", "west"));
assert!(!p.propagates_to_zone(b"/secret/@meta/keyver", "east", "west"),
"@meta is zone-scoped too, which is what gives each zone its own key version");
assert!(p.propagates_to_zone(b"/secret/encrypted/db_pw", "", ""));
assert!(!p.propagates_to_zone(b"/secret/db_pw", "east", "east"));
assert!(p.propagates_to_zone(b"/q/ch:orders/1", "east", "west"));
assert!(!p.is_zone_scoped(b"/q/ch:orders/1"));
}
#[test]
fn an_unzoned_policy_does_not_scope_anything() {
let p = PrefixPolicy::new("/secret/", "/secret/encrypted/", "/secret/", "");
assert!(!p.is_zone_scoped(b"/secret/encrypted/db_pw"));
assert!(p.propagates_to_zone(b"/secret/encrypted/db_pw", "east", "west"));
}
fn far() -> Option<std::net::SocketAddr> { "10.0.0.7:2379".parse().ok() }
#[test]
fn an_exception_to_local_only_is_served_to_peers() {
let p = skv().with_local_only_except("/secret/encrypted/");
assert!(p.may_serve(b"/secret/encrypted/db_pw", far()));
assert!(!p.may_serve(b"/secret/db_pw", far()));
assert!(!p.may_serve(b"/secret/@meta/keyver", far()));
}
#[test]
fn a_remote_range_over_a_local_only_subtree_is_refused() {
let p = skv();
assert!(!p.range_may_serve(b"", &[0], far()));
assert!(!p.range_may_serve(b"/r", b"/t", far()));
assert!(!p.range_may_serve(b"/secret/", b"/secret0", far()));
assert!(!p.range_may_serve(b"/secret/db_pw", b"", far()));
assert!(p.range_may_serve(b"/q/", b"/q0", far()));
assert!(p.range_may_serve(b"/a", b"/b", far()));
assert!(p.range_may_serve(b"/t", &[0], far()), "from past the subtree on");
assert!(p.range_may_serve(b"", &[0], "127.0.0.1:2379".parse().ok()));
assert!(p.range_may_serve(b"", &[0], None));
}
#[test]
fn a_range_wholly_inside_an_exception_is_served() {
let p = skv().with_local_only_except("/secret/encrypted/");
assert!(p.range_may_serve(b"/secret/encrypted/", b"/secret/encrypted0", far()));
assert!(p.range_may_serve(b"/secret/encrypted/db_pw", b"", far()));
assert!(!p.range_may_serve(b"/secret/", b"/secret0", far()), "the whole subtree is more than the exception");
}
#[test]
fn a_readonly_prefix_refuses_every_api_write() {
let p = PrefixPolicy::default().with_readonly("/secret/");
assert!(!p.is_empty());
assert!(!p.writable(b"/secret/encrypted/x"));
assert!(p.writable(b"/q/ch:orders/1"));
assert!(!p.range_writable(b"", &[0]), "a delete of everything reaches it");
assert!(!p.range_writable(b"/secret/", b"/secret0"));
assert!(p.range_writable(b"/q/", b"/q0"));
assert!(p.propagates(b"/secret/x"));
assert!(p.may_serve(b"/secret/x", far()));
}
#[test]
fn prefix_ends_follow_etcd() {
assert_eq!(prefix_end(b"/secret/"), Some(b"/secret0".to_vec()));
assert_eq!(prefix_end(&[b'a', 0xff]), Some(b"b".to_vec()));
assert_eq!(prefix_end(&[0xff, 0xff]), None);
assert!(is_remote(far()));
assert!(!is_remote(None));
assert!(!is_remote("[::1]:1".parse().ok()));
}
#[test]
fn a_non_utf8_key_is_handled_and_not_panicked_on() {
let p = skv();
assert!(p.propagates(&[0xff, 0xfe, 0x00]));
assert!(p.may_serve(&[0xff, 0xfe, 0x00], "10.0.0.7:1".parse().ok()));
}
}