use std::collections::{HashMap, HashSet};
use std::io::{Read, Write};
use crate::cache::{BoundedCache, CacheWeight};
use crate::checksum::Crc32;
use crate::content::node::{NodeState, PropertyValues};
use crate::content::property::PropertyValue;
use crate::content::value::{BinaryValue, read_binary_stream};
use crate::error::{Error, Result};
use crate::index::IndexError;
use crate::index::lanes::AsyncLanes;
use crate::segment::record::RecordIdentifier;
use crate::store::Repository;
const BINARY_BUFFER_BYTES: usize = 64 * 1024;
const BINARY_CHECKSUM_CACHE_BUDGET_BYTES: usize = 16 * 1024 * 1024;
const MAXIMUM_REPORTED_LOOKUP_FAILURES: usize = 64;
const CHECKPOINT_PATH_PREFIX: &str = "#checkpoint";
const SUPER_ROOT_PATH: &str = "#super-root";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct DigestSummary {
pub nodes: u64,
pub properties: u64,
pub excluded_properties: u64,
pub binaries: u64,
pub binary_bytes: u64,
pub checkpoints: u64,
pub reported_lookup_failures: Vec<String>,
pub lookup_failures: u64,
pub dangling_async_checkpoints: Vec<String>,
}
impl DigestSummary {
#[must_use]
pub fn is_clean(&self) -> bool {
self.lookup_failures == 0 && self.dangling_async_checkpoints.is_empty()
}
fn record_lookup_failure(&mut self, detail: String) {
self.lookup_failures += 1;
if self.reported_lookup_failures.len() < MAXIMUM_REPORTED_LOOKUP_FAILURES {
self.reported_lookup_failures.push(detail);
}
}
}
pub fn digest_repository<Output: Write + ?Sized>(
repository: &Repository,
output: &mut Output,
) -> Result<DigestSummary> {
digest_repository_excluding(repository, &[], &[], output)
}
pub fn digest_repository_excluding<Output: Write + ?Sized>(
repository: &Repository,
excluded_content_prefixes: &[String],
excluded_property_prefixes: &[String],
output: &mut Output,
) -> Result<DigestSummary> {
let mut summary = DigestSummary::default();
let mut renderer = Renderer {
repository,
buffer: vec![0u8; BINARY_BUFFER_BYTES],
binary_checksums: BoundedCache::new(BINARY_CHECKSUM_CACHE_BUDGET_BYTES),
excluded_property_prefixes,
};
for (label, prefixes) in [
("#excluded", excluded_content_prefixes),
("#excluded-properties", excluded_property_prefixes),
] {
if prefixes.is_empty() {
continue;
}
let mut sorted: Vec<&String> = prefixes.iter().collect();
sorted.sort();
let mut header = String::from(label);
for prefix in sorted {
header.push('\t');
header.push_str(&escape(prefix));
}
header.push('\n');
output
.write_all(header.as_bytes())
.map_err(Error::InputOutput)?;
}
renderer.walk_excluding(
&repository.content_root()?,
"",
excluded_content_prefixes,
output,
&mut summary,
)?;
renderer.emit_node(&repository.head(), SUPER_ROOT_PATH, output, &mut summary)?;
let checkpoints = repository.checkpoints()?;
let mut checkpoint_names = HashSet::with_capacity(checkpoints.len());
let mut sorted = checkpoints;
sorted.sort_by(|left, right| left.0.as_bytes().cmp(right.0.as_bytes()));
for (name, node) in &sorted {
checkpoint_names.insert(name.clone());
summary.checkpoints += 1;
let path = format!("{CHECKPOINT_PATH_PREFIX}/{}", escape(name));
renderer.walk_excluding(node, &path, &[], output, &mut summary)?;
}
summary.dangling_async_checkpoints =
AsyncLanes::dangling_checkpoints(&repository.content_root()?, &repository.head()).map_err(
|error| match error {
IndexError::Record(source) => source,
other => Error::InvalidFormat {
details: other.to_string(),
},
},
)?;
output.flush().map_err(Error::InputOutput)?;
Ok(summary)
}
struct Renderer<'repository> {
repository: &'repository Repository,
buffer: Vec<u8>,
binary_checksums: BoundedCache<RecordIdentifier, CachedBinaryChecksum>,
excluded_property_prefixes: &'repository [String],
}
#[derive(Clone, Copy)]
struct CachedBinaryChecksum {
read_bytes: u64,
checksum: u32,
}
impl CacheWeight for CachedBinaryChecksum {
fn cache_weight(&self) -> usize {
std::mem::size_of::<Self>()
}
}
enum Step<'provider> {
Visit {
node: NodeState<'provider>,
path: String,
},
Leave {
record: RecordIdentifier,
},
}
fn excluded(path: &str, prefixes: &[String]) -> bool {
prefixes.iter().any(|prefix| {
path.strip_prefix(prefix.as_str())
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
})
}
impl Renderer<'_> {
fn walk_excluding<Output: Write + ?Sized>(
&mut self,
root: &NodeState<'_>,
root_path: &str,
excluded_prefixes: &[String],
output: &mut Output,
summary: &mut DigestSummary,
) -> Result<()> {
let mut stack = vec![Step::Visit {
node: *root,
path: root_path.to_owned(),
}];
let mut ancestors: HashSet<RecordIdentifier> = HashSet::new();
while let Some(step) = stack.pop() {
match step {
Step::Leave { record } => {
ancestors.remove(&record);
}
Step::Visit { node, path } => {
let record = node.record_identifier();
if !ancestors.insert(record) {
return Err(Error::InvalidFormat {
details: format!(
"the node at {} is its own ancestor, so the tree cannot be \
rendered; this is corruption, not deep content",
display_path(&path)
),
});
}
stack.push(Step::Leave { record });
self.emit_node(&node, &path, output, summary)?;
let mut entries = node.child_node_entries()?;
entries.sort_by(|left, right| left.0.as_bytes().cmp(right.0.as_bytes()));
for (name, child) in &entries {
let found = node.child_node(name)?;
if found.map(|found| found.record_identifier())
!= Some(child.record_identifier())
{
summary.record_lookup_failure(format!(
"{}: child {name:?} is present when enumerated but not \
reachable by lookup, so an application resolving that path \
finds nothing",
display_path(&path)
));
}
}
for (name, child) in entries.into_iter().rev() {
let child_path = format!("{path}/{}", escape(&name));
if excluded(&child_path, excluded_prefixes) {
continue;
}
stack.push(Step::Visit {
node: child,
path: child_path,
});
}
}
}
}
Ok(())
}
fn emit_node<Output: Write + ?Sized>(
&mut self,
node: &NodeState<'_>,
path: &str,
output: &mut Output,
summary: &mut DigestSummary,
) -> Result<()> {
summary.nodes += 1;
let mut properties = node.properties()?;
if !self.excluded_property_prefixes.is_empty() {
let before = properties.len();
properties.retain(|property| {
!self
.excluded_property_prefixes
.iter()
.any(|prefix| property.name.starts_with(prefix.as_str()))
});
summary.excluded_properties += (before - properties.len()) as u64;
}
properties.sort_by(|left, right| left.name.as_bytes().cmp(right.name.as_bytes()));
let mut line = String::with_capacity(64);
line.push_str(&display_path(path));
for property in &properties {
summary.properties += 1;
line.push('\t');
line.push_str(&escape(&property.name));
line.push('=');
let (values, arity) = match &property.values {
PropertyValues::Single(value) => (std::slice::from_ref(value), ""),
PropertyValues::Multiple(values) => (values.as_slice(), "[]"),
};
line.push_str(property.property_type.jcr_name());
line.push_str(arity);
line.push(':');
for (index, value) in values.iter().enumerate() {
if index > 0 {
line.push('\u{1F}');
}
line.push_str(&self.render_value(value, summary)?);
}
}
line.push('\n');
output
.write_all(line.as_bytes())
.map_err(Error::InputOutput)?;
for property in &properties {
let found = node.property(&property.name)?;
let agrees = found.as_ref().is_some_and(|found| {
found.property_type == property.property_type && found.values == property.values
});
if !agrees {
summary.record_lookup_failure(format!(
"{}: property {:?} is present when enumerated but {} when looked up by name",
display_path(path),
property.name,
if found.is_some() {
"decodes differently"
} else {
"absent"
}
));
}
}
Ok(())
}
fn render_value(
&mut self,
value: &PropertyValue,
summary: &mut DigestSummary,
) -> Result<String> {
match value {
PropertyValue::Binary(BinaryValue::Inline {
length,
record_identifier,
}) => {
let folded = self.fold_inline_binary(*record_identifier)?;
summary.binaries += 1;
summary.binary_bytes += folded.read_bytes;
Ok(format!(
"{length}/{}@{:08x}",
folded.read_bytes, folded.checksum
))
}
PropertyValue::Binary(BinaryValue::External { blob_identifier }) => {
Ok(format!("external:{}", escape(blob_identifier)))
}
other => Ok(escape(&other.as_text().unwrap_or_default())),
}
}
fn fold_inline_binary(
&mut self,
record_identifier: RecordIdentifier,
) -> Result<CachedBinaryChecksum> {
if let Some(cached) = self.binary_checksums.get(&record_identifier) {
return Ok(cached);
}
let mut stream = read_binary_stream(self.repository, record_identifier)?;
let mut running = Crc32::new();
let mut read_bytes: u64 = 0;
loop {
let count = stream.read(&mut self.buffer).map_err(Error::InputOutput)?;
if count == 0 {
break;
}
running.update(&self.buffer[..count]);
read_bytes += count as u64;
}
let folded = CachedBinaryChecksum {
read_bytes,
checksum: running.finish(),
};
self.binary_checksums.insert(record_identifier, folded);
Ok(folded)
}
}
fn display_path(path: &str) -> String {
if path.is_empty() {
"/".to_owned()
} else {
path.to_owned()
}
}
fn escape(text: &str) -> String {
if !text
.chars()
.any(|character| matches!(character, '\\' | '\t' | '\n' | '\r' | '\u{1F}'))
{
return text.to_owned();
}
let mut escaped = String::with_capacity(text.len() + 8);
for character in text.chars() {
match character {
'\\' => escaped.push_str("\\\\"),
'\t' => escaped.push_str("\\t"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\u{1F}' => escaped.push_str("\\u001f"),
other => escaped.push(other),
}
}
escaped
}
#[must_use]
pub fn parse_digest(digest: &str) -> HashMap<&str, &str> {
digest
.lines()
.filter(|line| !line.is_empty())
.map(|line| match line.split_once('\t') {
Some((path, properties)) => (path, properties),
None => (line, ""),
})
.collect()
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DigestDifference {
pub removed: Vec<String>,
pub added: Vec<String>,
pub changed: Vec<String>,
}
impl DigestDifference {
#[must_use]
pub fn is_empty(&self) -> bool {
self.removed.is_empty() && self.added.is_empty() && self.changed.is_empty()
}
}
#[must_use]
pub fn compare_digests(baseline: &str, current: &str) -> DigestDifference {
let baseline_nodes = parse_digest(baseline);
let current_nodes = parse_digest(current);
let mut difference = DigestDifference::default();
for (path, properties) in &baseline_nodes {
match current_nodes.get(path) {
None => difference.removed.push((*path).to_owned()),
Some(current_properties) if current_properties != properties => {
difference.changed.push((*path).to_owned());
}
Some(_) => {}
}
}
for path in current_nodes.keys() {
if !baseline_nodes.contains_key(path) {
difference.added.push((*path).to_owned());
}
}
difference.removed.sort();
difference.added.sort();
difference.changed.sort();
difference
}
#[cfg(test)]
mod tests {
use super::{compare_digests, escape, parse_digest};
#[test]
fn escaping_is_unambiguous_for_every_reserved_byte() {
assert_eq!(escape("plain"), "plain");
assert_eq!(escape("a\tb"), "a\\tb");
assert_eq!(escape("a\nb"), "a\\nb");
assert_eq!(escape("a\rb"), "a\\rb");
assert_eq!(escape("a\u{1F}b"), "a\\u001fb");
assert_eq!(escape("a\\b"), "a\\\\b");
assert_ne!(escape("a\\tb"), escape("a\tb"));
}
#[test]
fn parsing_keeps_a_node_with_no_properties() {
let parsed = parse_digest("/\n/content\tjcr:primaryType=Name:nt:folder\n");
assert_eq!(parsed.get("/"), Some(&""));
assert_eq!(
parsed.get("/content"),
Some(&"jcr:primaryType=Name:nt:folder")
);
}
#[test]
fn comparing_reports_the_paths_that_differ_not_merely_that_they_do() {
let baseline = "/\n/a\tp=String:1\n/b\tp=String:2\n";
let current = "/\n/a\tp=String:9\n/c\tp=String:3\n";
let difference = compare_digests(baseline, current);
assert_eq!(difference.changed, vec!["/a".to_owned()]);
assert_eq!(difference.removed, vec!["/b".to_owned()]);
assert_eq!(difference.added, vec!["/c".to_owned()]);
assert!(!difference.is_empty());
assert!(compare_digests(baseline, baseline).is_empty());
}
#[test]
fn arity_alone_is_a_difference() {
let single = "/a\ttags=String:alpha\n";
let multiple = "/a\ttags=String[]:alpha\n";
assert_eq!(compare_digests(single, multiple).changed, vec!["/a"]);
}
}