#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::borrow::Cow;
use std::cell::Cell;
use serde_json::Value;
use super::error::{ConstelaError, Diagnostic, codes};
pub const SLOT_BYTES: usize = 8;
#[derive(Debug)]
pub struct Budget {
remaining: Cell<usize>,
limit: usize,
}
impl Budget {
pub const fn new(limit: usize) -> Self {
Self {
remaining: Cell::new(limit),
limit,
}
}
pub fn charge(&self, bytes: usize, path: &str) -> Result<(), ConstelaError> {
let remaining = self.remaining.get();
if bytes > remaining {
return Err(ConstelaError::Render(Diagnostic::new(
path,
codes::RENDER_LIMIT,
format!(
"values built or kept here go over the {}-byte allocation budget",
self.limit
),
)));
}
self.remaining.set(remaining.saturating_sub(bytes));
Ok(())
}
pub fn charge_value(&self, value: &Value, path: &str) -> Result<(), ConstelaError> {
self.charge(approx_bytes(value, self.remaining.get()), path)
}
pub fn own(&self, value: Cow<'_, Value>, path: &str) -> Result<Value, ConstelaError> {
match value {
Cow::Owned(value) => Ok(value),
Cow::Borrowed(value) => {
self.charge_value(value, path)?;
Ok(value.clone())
}
}
}
pub fn own_str(&self, text: Cow<'_, str>, path: &str) -> Result<String, ConstelaError> {
match text {
Cow::Owned(text) => Ok(text),
Cow::Borrowed(text) => {
self.charge(text.len(), path)?;
Ok(text.to_string())
}
}
}
}
pub struct ChargingWriter<'b> {
budget: &'b Budget,
path: &'b str,
out: Vec<u8>,
refused: Option<ConstelaError>,
}
impl<'b> ChargingWriter<'b> {
pub const fn new(budget: &'b Budget, path: &'b str) -> Self {
Self {
budget,
path,
out: Vec::new(),
refused: None,
}
}
pub fn serialize(mut self, value: &impl serde::Serialize) -> Result<String, ConstelaError> {
let written = serde_json::to_writer(&mut self, value);
if let Some(refused) = self.refused {
return Err(refused);
}
let path = self.path;
let failed =
|message: String| ConstelaError::Render(Diagnostic::new(path, codes::EVAL, message));
written.map_err(|err| failed(format!("value could not be serialized: {err}")))?;
String::from_utf8(self.out)
.map_err(|err| failed(format!("serialized value is not UTF-8: {err}")))
}
}
impl std::io::Write for ChargingWriter<'_> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if let Err(refused) = self.budget.charge(buf.len(), self.path) {
self.refused = Some(refused);
return Err(std::io::Error::other("allocation budget spent"));
}
self.out.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[derive(Debug)]
pub struct Output {
written: Cell<usize>,
limit: usize,
}
impl Output {
pub const fn new(limit: usize) -> Self {
Self {
written: Cell::new(0),
limit,
}
}
pub fn check(&self, extra: usize, path: &str) -> Result<(), ConstelaError> {
if self.written.get().saturating_add(extra) > self.limit {
return Err(ConstelaError::Render(Diagnostic::new(
path,
codes::RENDER_LIMIT,
format!(
"render would produce more than {} bytes of markup",
self.limit
),
)));
}
Ok(())
}
pub fn write(&self, out: &mut String, text: &str, path: &str) -> Result<(), ConstelaError> {
self.check(text.len(), path)?;
self.written
.set(self.written.get().saturating_add(text.len()));
out.push_str(text);
Ok(())
}
}
pub fn approx_bytes(value: &Value, cap: usize) -> usize {
let mut total = 0usize;
let mut stack = vec![value];
while let Some(current) = stack.pop() {
total = total.saturating_add(match current {
Value::Null | Value::Bool(_) | Value::Number(_) => SLOT_BYTES,
Value::String(text) => text.len(),
Value::Array(items) => SLOT_BYTES.saturating_mul(items.len()),
Value::Object(entries) => entries
.keys()
.map(|key| key.len().saturating_add(SLOT_BYTES))
.fold(0usize, usize::saturating_add),
});
if total > cap {
return total;
}
match current {
Value::Array(items) => stack.extend(items.iter()),
Value::Object(entries) => stack.extend(entries.iter().map(|(_, value)| value)),
_ => {}
}
}
total
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_charge_spends_and_an_overdraft_fails() {
let budget = Budget::new(10);
budget.charge(6, "a").expect("fits");
budget.charge(4, "b").expect("fits exactly");
let err = budget.charge(1, "c").expect_err("over");
assert_eq!(err.diagnostics()[0].code, codes::RENDER_LIMIT);
assert_eq!(err.diagnostics()[0].path, "c");
}
#[test]
fn a_failed_charge_spends_nothing() {
let budget = Budget::new(10);
budget.charge(11, "a").expect_err("over");
budget
.charge(10, "b")
.expect("the full budget is still there");
}
#[test]
fn owning_a_borrowed_value_charges_its_size() {
let budget = Budget::new(100);
let value = json!("x".repeat(60));
let owned = budget.own(Cow::Borrowed(&value), "p").expect("fits");
assert_eq!(owned, value);
budget
.own(Cow::Borrowed(&value), "p")
.expect_err("second copy is over");
}
#[test]
fn owning_an_owned_value_charges_nothing() {
let budget = Budget::new(0);
let owned = budget
.own(Cow::Owned(json!("x".repeat(60))), "p")
.expect("built values were charged when built");
assert_eq!(owned.as_str().map(str::len), Some(60));
}
#[test]
fn a_charging_writer_charges_the_exact_text() {
let budget = Budget::new(100);
let text = ChargingWriter::new(&budget, "p")
.serialize(&json!({"a": "x\"y"}))
.expect("fits");
assert_eq!(text, r#"{"a":"x\"y"}"#);
budget
.charge(100 - text.len(), "p")
.expect("charged exactly");
budget.charge(1, "p").expect_err("nothing left");
}
#[test]
fn a_charging_writer_stops_at_the_budget() {
let budget = Budget::new(10);
let err = ChargingWriter::new(&budget, "p")
.serialize(&json!("x".repeat(100)))
.expect_err("over");
assert_eq!(err.diagnostics()[0].code, codes::RENDER_LIMIT);
}
#[test]
fn output_counts_every_buffer_against_one_limit() {
let output = Output::new(8);
let mut body = String::new();
let mut portal = String::new();
output.write(&mut body, "abcd", "a").expect("fits");
output
.write(&mut portal, "efgh", "b")
.expect("fits exactly");
output.write(&mut body, "i", "c").expect_err("over");
assert_eq!(body, "abcd");
assert_eq!(portal, "efgh");
}
#[test]
fn approx_bytes_counts_strings_keys_and_slots() {
let value = json!({"ab": ["xyz", 1]});
let expected = (2 + SLOT_BYTES) + 2 * SLOT_BYTES + 3 + SLOT_BYTES;
assert_eq!(approx_bytes(&value, usize::MAX), expected);
}
#[test]
fn approx_bytes_stops_once_past_the_cap() {
let value = json!(["x".repeat(100), "y".repeat(100)]);
assert!(approx_bytes(&value, 50) > 50);
assert!(approx_bytes(&value, 50) < 2 * SLOT_BYTES + 200);
}
}