use std::collections::BTreeSet;
use std::fmt;
pub const PATHS_FILE: &str = ".onelf/paths";
pub const INFERRED_PATHS_FILE: &str = ".onelf/inferred-paths";
pub const MAX_PAIRS: usize = 1024;
pub const MAX_ABSOLUTE_BYTES: usize = 4095;
pub const MAX_RELATIVE_BYTES: usize = 2048;
pub const MAX_RECORD_BYTES: usize = 256 * 1024;
pub(crate) const EXCERPT_CHARS: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathPair {
pub absolute: String,
pub relative: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathsErrorKind {
RecordTooLarge,
TooManyPairs,
NotUtf8,
EmptyLine,
MissingSeparator,
TrailingField,
NotAbsolute,
AbsoluteTooLong,
RelativeIsAbsolute,
RelativeTooLong,
Escapes,
Unnormalized,
ControlCharacter,
DuplicateTarget,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathsError {
kind: PathsErrorKind,
at: Locus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Locus {
Record,
Line(usize),
LinePair(usize, String),
Pair(String),
}
impl PathsError {
fn record(kind: PathsErrorKind) -> Self {
Self {
kind,
at: Locus::Record,
}
}
fn line(kind: PathsErrorKind, index: usize) -> Self {
Self {
kind,
at: Locus::Line(index + 1),
}
}
fn pair(kind: PathsErrorKind, line: Option<usize>, absolute: &str, relative: &str) -> Self {
let quoted = format!("\"{}\" = \"{}\"", excerpt(absolute), excerpt(relative));
let at = match line {
Some(index) => Locus::LinePair(index + 1, quoted),
None => Locus::Pair(quoted),
};
Self { kind, at }
}
pub fn kind(&self) -> PathsErrorKind {
self.kind
}
}
impl PathsErrorKind {
pub(crate) fn reason(self) -> String {
match self {
Self::RecordTooLarge => {
format!("the record is over {MAX_RECORD_BYTES} bytes")
}
Self::TooManyPairs => format!("the record holds over {MAX_PAIRS} pairs"),
Self::NotUtf8 => "the record is not UTF-8".into(),
Self::EmptyLine => "the line holds no pair".into(),
Self::MissingSeparator => "the line has no tab between its paths".into(),
Self::TrailingField => "the line goes on after the bundle path".into(),
Self::NotAbsolute => "the target is not an absolute path".into(),
Self::AbsoluteTooLong => {
format!("the target is over {MAX_ABSOLUTE_BYTES} bytes")
}
Self::RelativeIsAbsolute => {
"the bundle path must be relative to the package root".into()
}
Self::RelativeTooLong => {
format!("the bundle path is over {MAX_RELATIVE_BYTES} bytes")
}
Self::Escapes => "the bundle path leaves the package".into(),
Self::Unnormalized => {
"a path is not normalized, or names no directory below its base".into()
}
Self::ControlCharacter => "a path holds a control character".into(),
Self::DuplicateTarget => "the target is paired more than once".into(),
}
}
}
impl fmt::Display for PathsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let reason = self.kind.reason();
match &self.at {
Locus::Record => f.write_str(&reason),
Locus::Line(number) => write!(f, "line {number}: {reason}"),
Locus::LinePair(number, quoted) => write!(f, "line {number}, {quoted}: {reason}"),
Locus::Pair(quoted) => write!(f, "{quoted}: {reason}"),
}
}
}
impl std::error::Error for PathsError {}
pub fn decode(bytes: &[u8]) -> Result<Vec<PathPair>, PathsError> {
if bytes.len() > MAX_RECORD_BYTES {
return Err(PathsError::record(PathsErrorKind::RecordTooLarge));
}
if bytes.is_empty() {
return Ok(Vec::new());
}
let text =
std::str::from_utf8(bytes).map_err(|_| PathsError::record(PathsErrorKind::NotUtf8))?;
let mut pairs = Vec::new();
let mut targets = BTreeSet::new();
for (index, line) in text.split('\n').enumerate() {
if index >= MAX_PAIRS {
return Err(PathsError::record(PathsErrorKind::TooManyPairs));
}
if line.is_empty() {
return Err(PathsError::line(PathsErrorKind::EmptyLine, index));
}
let Some((absolute, relative)) = line.split_once('\t') else {
return Err(PathsError::line(PathsErrorKind::MissingSeparator, index));
};
if relative.contains('\t') {
return Err(PathsError::pair(
PathsErrorKind::TrailingField,
Some(index),
absolute,
relative,
));
}
check_pair(Some(index), absolute, relative, &mut targets)?;
pairs.push(PathPair {
absolute: absolute.to_owned(),
relative: relative.to_owned(),
});
}
Ok(pairs)
}
pub fn encode(pairs: &[PathPair]) -> Result<Vec<u8>, PathsError> {
if pairs.len() > MAX_PAIRS {
return Err(PathsError::record(PathsErrorKind::TooManyPairs));
}
let mut targets = BTreeSet::new();
let mut out = Vec::new();
for (index, pair) in pairs.iter().enumerate() {
check_pair(None, &pair.absolute, &pair.relative, &mut targets)?;
if index > 0 {
out.push(b'\n');
}
out.extend_from_slice(pair.absolute.as_bytes());
out.push(b'\t');
out.extend_from_slice(pair.relative.as_bytes());
if out.len() > MAX_RECORD_BYTES {
return Err(PathsError::record(PathsErrorKind::RecordTooLarge));
}
}
Ok(out)
}
fn check_pair<'a>(
line: Option<usize>,
absolute: &'a str,
relative: &str,
targets: &mut BTreeSet<&'a str>,
) -> Result<(), PathsError> {
let refuse = |kind| Err(PathsError::pair(kind, line, absolute, relative));
if let Some(kind) = absolute_error(absolute) {
return refuse(kind);
}
if let Some(kind) = relative_error(relative) {
return refuse(kind);
}
if !targets.insert(absolute) {
return refuse(PathsErrorKind::DuplicateTarget);
}
Ok(())
}
pub fn pair_refusal(absolute: &str, relative: &str) -> Option<String> {
check_pair(None, absolute, relative, &mut BTreeSet::new())
.err()
.map(|error| error.to_string())
}
pub(crate) fn relative_error(relative: &str) -> Option<PathsErrorKind> {
if relative.len() > MAX_RELATIVE_BYTES {
return Some(PathsErrorKind::RelativeTooLong);
}
if relative.chars().any(char::is_control) {
return Some(PathsErrorKind::ControlCharacter);
}
if relative.starts_with('/') {
return Some(PathsErrorKind::RelativeIsAbsolute);
}
if relative.split('/').any(|part| part == "..") {
return Some(PathsErrorKind::Escapes);
}
if relative.split('/').any(|part| matches!(part, "" | ".")) {
return Some(PathsErrorKind::Unnormalized);
}
None
}
pub(crate) fn absolute_error(absolute: &str) -> Option<PathsErrorKind> {
if absolute.len() > MAX_ABSOLUTE_BYTES {
return Some(PathsErrorKind::AbsoluteTooLong);
}
if absolute.chars().any(char::is_control) {
return Some(PathsErrorKind::ControlCharacter);
}
let Some(below_root) = absolute.strip_prefix('/') else {
return Some(PathsErrorKind::NotAbsolute);
};
if below_root
.split('/')
.any(|part| matches!(part, "" | "." | ".."))
{
return Some(PathsErrorKind::Unnormalized);
}
None
}
pub(crate) fn excerpt(path: &str) -> String {
path.chars()
.take(EXCERPT_CHARS)
.flat_map(char::escape_default)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn pair(absolute: &str, relative: &str) -> PathPair {
PathPair {
absolute: absolute.into(),
relative: relative.into(),
}
}
fn refused(bytes: &[u8]) -> PathsErrorKind {
decode(bytes).unwrap_err().kind()
}
fn refused_pair(absolute: &str, relative: &str) -> PathsErrorKind {
let encoded = encode(&[pair(absolute, relative)]).unwrap_err().kind();
let line = format!("{absolute}\t{relative}");
assert_eq!(refused(line.as_bytes()), encoded, "{line:?}");
encoded
}
#[test]
fn a_record_round_trips_byte_for_byte() {
let bytes = b"/usr/share/galculator\tshare/galculator\n\
/opt/My Vendor/data\tshare/vendor data\n\
/usr/share/fixture-again\tshare/galculator";
let pairs = decode(bytes).unwrap();
assert_eq!(
pairs,
vec![
pair("/usr/share/galculator", "share/galculator"),
pair("/opt/My Vendor/data", "share/vendor data"),
pair("/usr/share/fixture-again", "share/galculator"),
]
);
assert_eq!(encode(&pairs).unwrap(), bytes);
}
#[test]
fn the_empty_record_holds_no_pairs() {
assert_eq!(decode(b"").unwrap(), Vec::new());
assert_eq!(encode(&[]).unwrap(), b"");
}
#[test]
fn a_record_the_packer_would_not_write_is_refused() {
assert_eq!(refused(b"/a\tb\n"), PathsErrorKind::EmptyLine);
assert_eq!(refused(b"/a\tb\n\n/c\td"), PathsErrorKind::EmptyLine);
assert_eq!(refused(b"\n/a\tb"), PathsErrorKind::EmptyLine);
assert_eq!(refused(b"/a b"), PathsErrorKind::MissingSeparator);
assert_eq!(refused(b"/a\tb\tc"), PathsErrorKind::TrailingField);
assert_eq!(refused(b"/a\tb\t"), PathsErrorKind::TrailingField);
assert_eq!(refused(b"/a\tb\r"), PathsErrorKind::ControlCharacter);
assert_eq!(refused(b"/a\t\xffb"), PathsErrorKind::NotUtf8);
}
#[test]
fn the_target_must_be_absolute_and_normalized() {
for target in ["usr/share/x", " /usr/share/x", ""] {
assert_eq!(refused_pair(target, "share/x"), PathsErrorKind::NotAbsolute);
}
for target in [
"/",
"//usr",
"/usr//share",
"/usr/share/",
"/usr/./share",
"/usr/share/.",
"/usr/../etc",
"/..",
] {
assert_eq!(
refused_pair(target, "share/x"),
PathsErrorKind::Unnormalized,
"{target}"
);
}
}
#[test]
fn the_bundle_side_must_stay_inside_the_package() {
assert_eq!(
refused_pair("/usr/share/x", "/usr/share/x"),
PathsErrorKind::RelativeIsAbsolute
);
for rel in ["..", "../x", "share/../../x", "share/.."] {
assert_eq!(refused_pair("/usr/share/x", rel), PathsErrorKind::Escapes);
}
for rel in ["", ".", "./share", "share/", "share//x", "share/./x"] {
assert_eq!(
refused_pair("/usr/share/x", rel),
PathsErrorKind::Unnormalized,
"{rel:?}"
);
}
}
#[test]
fn a_control_character_is_refused_on_either_side() {
assert_eq!(
refused_pair("/usr/share/x\0y", "share/x"),
PathsErrorKind::ControlCharacter
);
assert_eq!(
refused_pair("/usr/share/x", "share/x\0"),
PathsErrorKind::ControlCharacter
);
assert_eq!(
encode(&[pair("/usr/share/x\ny", "share/x")])
.unwrap_err()
.kind(),
PathsErrorKind::ControlCharacter
);
assert_eq!(
encode(&[pair("/usr/share/x", "share\tx")])
.unwrap_err()
.kind(),
PathsErrorKind::ControlCharacter
);
assert_eq!(
refused_pair("/usr/share/x\u{85}", "share/x"),
PathsErrorKind::ControlCharacter
);
assert_eq!(
refused_pair("/usr/share/x", "share/\u{7f}"),
PathsErrorKind::ControlCharacter
);
}
#[test]
fn a_target_is_paired_once() {
let twice = [
pair("/usr/share/x", "share/a"),
pair("/usr/share/x", "share/b"),
];
assert_eq!(
encode(&twice).unwrap_err().kind(),
PathsErrorKind::DuplicateTarget
);
assert_eq!(
refused(b"/usr/share/x\tshare/a\n/usr/share/x\tshare/b"),
PathsErrorKind::DuplicateTarget
);
}
#[test]
fn path_lengths_are_bounded() {
let longest = format!("/{}", "a".repeat(MAX_ABSOLUTE_BYTES - 1));
assert!(encode(&[pair(&longest, "share/x")]).is_ok());
assert_eq!(
refused_pair(&format!("{longest}b"), "share/x"),
PathsErrorKind::AbsoluteTooLong
);
let longest = "a".repeat(MAX_RELATIVE_BYTES);
assert!(encode(&[pair("/usr/share/x", &longest)]).is_ok());
assert_eq!(
refused_pair("/usr/share/x", &format!("{longest}b")),
PathsErrorKind::RelativeTooLong
);
}
#[test]
fn the_pair_count_is_bounded() {
let pairs: Vec<PathPair> = (0..=MAX_PAIRS)
.map(|i| pair(&format!("/t/{i}"), "share/x"))
.collect();
let bytes = encode(&pairs[..MAX_PAIRS]).unwrap();
assert_eq!(decode(&bytes).unwrap().len(), MAX_PAIRS);
assert_eq!(
encode(&pairs).unwrap_err().kind(),
PathsErrorKind::TooManyPairs
);
let mut over = bytes;
over.extend_from_slice(b"\n/t/last\tshare/x");
assert_eq!(refused(&over), PathsErrorKind::TooManyPairs);
}
#[test]
fn the_record_size_is_bounded() {
let relative = "r".repeat(MAX_RELATIVE_BYTES);
let pairs: Vec<PathPair> = (0..MAX_PAIRS)
.map(|i| pair(&format!("/{i:0>1000}"), &relative))
.collect();
assert_eq!(
encode(&pairs).unwrap_err().kind(),
PathsErrorKind::RecordTooLarge
);
let oversized = vec![b'/'; MAX_RECORD_BYTES + 1];
assert_eq!(refused(&oversized), PathsErrorKind::RecordTooLarge);
}
#[test]
fn a_record_of_exactly_the_size_bound_decodes() {
let line = |i: usize, len: usize| format!("/{i:03}{}\tr", "a".repeat(len - 6));
let lines = MAX_RECORD_BYTES.div_ceil(4096);
let separators = lines - 1;
let mut text: Vec<String> = (0..lines - 1).map(|i| line(i, 4095)).collect();
let last = MAX_RECORD_BYTES - separators - 4095 * (lines - 1);
text.push(line(lines - 1, last));
let bytes = text.join("\n").into_bytes();
assert_eq!(bytes.len(), MAX_RECORD_BYTES);
let pairs = decode(&bytes).unwrap();
assert_eq!(encode(&pairs).unwrap(), bytes);
let mut over = bytes;
over.push(b'r');
assert_eq!(refused(&over), PathsErrorKind::RecordTooLarge);
let mut pairs = pairs;
pairs.last_mut().unwrap().relative.push('r');
assert_eq!(
encode(&pairs).unwrap_err().kind(),
PathsErrorKind::RecordTooLarge
);
}
#[test]
fn a_line_level_error_names_the_line() {
assert_eq!(
decode(b"/a\tb\n").unwrap_err().to_string(),
"line 2: the line holds no pair"
);
assert_eq!(
decode(b"/a\tb\n/c").unwrap_err().to_string(),
"line 2: the line has no tab between its paths"
);
}
#[test]
fn an_error_names_the_pair_it_refuses() {
let err = encode(&[
pair("/usr/share/ok", "share/ok"),
pair("/usr/share/x", "../outside"),
])
.unwrap_err();
assert_eq!(
err.to_string(),
"\"/usr/share/x\" = \"../outside\": the bundle path leaves the package"
);
assert_eq!(
decode(b"/usr/share/ok\tshare/ok\n/usr/share/x\t../outside")
.unwrap_err()
.to_string(),
"line 2, \"/usr/share/x\" = \"../outside\": the bundle path leaves the package"
);
let err = decode(b"/usr/share/x\tshare/\x01").unwrap_err();
assert!(err.to_string().contains("share/\\u{1}"), "{err}");
let long = "/".to_string() + &"a".repeat(10_000);
let err = encode(&[pair(&long, "share/x")]).unwrap_err();
assert!(err.to_string().len() < 400, "{err}");
}
}