use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::{GitCommitId, Sha256Hex};
pub const DATA_SET_VERSION: u32 = 1;
pub const MAX_DATA_FILE_BYTES: u64 =
crate::requests::GetContentInput::MAX_COMMENT_BUDGET as u64;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct DataSet {
pub agora_data_set: u32,
pub title: String,
pub description: String,
pub compiled_by: String,
pub repo: String,
pub commit: GitCommitId,
pub index: DataFilePath,
pub files: Vec<DataFile>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct DataFile {
pub path: DataFilePath,
pub bytes: u64,
pub sha256: Sha256Hex,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
impl DataSet {
pub fn validate(&self) -> Result<(), &'static str> {
if self.agora_data_set != DATA_SET_VERSION {
return Err("agora_data_set is not the version this writer writes");
}
let blank = |s: &str| s.trim().is_empty();
if blank(&self.title)
|| blank(&self.description)
|| blank(&self.compiled_by)
|| blank(&self.repo)
{
return Err(
"title, description, compiled_by and repo must all say something",
);
}
if self.files.is_empty() {
return Err("a data set lists at least one file");
}
if self.files.windows(2).any(|w| w[0].path >= w[1].path) {
return Err("files must be sorted by path, each listed once");
}
if self.files.iter().any(|f| f.bytes > MAX_DATA_FILE_BYTES) {
return Err(
"a file is larger than MAX_DATA_FILE_BYTES (256 KiB); split it",
);
}
if self.file(&self.index).is_none() {
return Err("the index must be one of the listed files");
}
if self.files.iter().any(|f| {
f.note
.as_deref()
.is_some_and(|n| blank(n) || n.contains(['\n', '\r']))
}) {
return Err("a file's note is one line, or left out");
}
Ok(())
}
pub fn file(&self, path: &DataFilePath) -> Option<&DataFile> {
self.files
.binary_search_by(|f| f.path.cmp(path))
.ok()
.map(|i| &self.files[i])
.or_else(|| self.files.iter().find(|f| &f.path == path))
}
pub fn total_bytes(&self) -> u64 {
self.files.iter().map(|f| f.bytes).sum()
}
pub fn under(&self, prefix: &str) -> (u64, u64) {
self.files
.iter()
.filter(|f| f.path.as_str().starts_with(prefix))
.fold((0, 0), |(n, b), f| (n + 1, b + f.bytes))
}
fn top_directories(&self) -> Vec<&str> {
let mut dirs: Vec<&str> = Vec::new();
for f in &self.files {
if let Some((dir, _)) = f.path.as_str().split_once('/')
&& !dirs.contains(&dir)
{
dirs.push(dir);
}
}
dirs
}
pub fn resolve(
&self,
asked: &str,
) -> Result<DataSetTarget<'_>, DataSetLookupError> {
let trimmed = asked.trim();
let path = trimmed.strip_prefix("./").unwrap_or(trimmed);
let path = path.trim_start_matches('/');
let lookup_error = |kind| DataSetLookupError {
asked: asked.to_string(),
kind,
};
if path.is_empty() {
return Err(lookup_error(DataSetLookupFailure::NotFound));
}
let tops = self.top_directories();
let file_at =
|p: &str| p.parse().ok().and_then(|p: DataFilePath| self.file(&p));
let directory = |prefix: String| {
let (files, bytes) = self.under(&prefix);
(files > 0).then_some((prefix, files, bytes))
};
let one = |mut found: Vec<String>| match found.len() {
1 => Ok(found.pop()),
0 => Ok(None),
_ => Err(lookup_error(DataSetLookupFailure::Ambiguous(found))),
};
if path.ends_with('/') {
if directory(path.to_string()).is_some() {
return Ok(DataSetTarget::Directory {
prefix: path.to_string(),
asked: None,
});
}
let found = tops
.iter()
.filter_map(|top| directory(format!("{top}/{path}")))
.map(|(prefix, ..)| prefix)
.collect();
return match one(found)? {
Some(prefix) => Ok(DataSetTarget::Directory {
asked: Some(asked.to_string()),
prefix,
}),
None => Err(lookup_error(DataSetLookupFailure::NotFound)),
};
}
if let Some(file) = file_at(path) {
return Ok(DataSetTarget::File { file, asked: None });
}
let not_a_file = |(prefix, files, bytes)| {
lookup_error(DataSetLookupFailure::Directory {
prefix,
files,
bytes,
})
};
if let Some(dir) = directory(format!("{path}/")) {
return Err(not_a_file(dir));
}
let found = tops
.iter()
.map(|top| format!("{top}/{path}"))
.filter(|p| file_at(p).is_some())
.collect();
if let Some(full) = one(found)?
&& let Some(file) = file_at(&full)
{
return Ok(DataSetTarget::File {
file,
asked: Some(asked.to_string()),
});
}
let dirs: Vec<_> = tops
.iter()
.filter_map(|top| directory(format!("{top}/{path}/")))
.collect();
match dirs.len() {
0 => Err(lookup_error(DataSetLookupFailure::NotFound)),
1 => Err(not_a_file(dirs.into_iter().next().expect("one"))),
_ => Err(lookup_error(DataSetLookupFailure::Ambiguous(
dirs.into_iter().map(|(prefix, ..)| prefix).collect(),
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DataSetTarget<'a> {
File {
file: &'a DataFile,
asked: Option<String>,
},
Directory {
prefix: String,
asked: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("attachment={asked:?}: {kind}")]
pub struct DataSetLookupError {
pub asked: String,
pub kind: DataSetLookupFailure,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DataSetLookupFailure {
#[error(
"{prefix} is a directory of this data set ({files} files, {bytes} \
bytes), not a file. List it with attachment=\"{prefix}\" (a directory \
ends in `/`), or read one of its files by its path."
)]
Directory {
prefix: String,
files: u64,
bytes: u64,
},
#[error(
"that path is under more than one of this data set's top directories: \
{}. Ask for one of them by its full path.",
.0.join(", ")
)]
Ambiguous(Vec<String>),
#[error(
"no file or directory of this data set is at that path. Paths are \
relative to the data repository's root (a path relative to a \
directory at the root also works when only one directory holds it); \
a directory ends in `/`."
)]
NotFound,
}
impl DataFile {
pub fn of(path: DataFilePath, bytes: &[u8]) -> Self {
Self {
path,
bytes: bytes.len() as u64,
sha256: Sha256Hex::from(<[u8; 32]>::from(Sha256::digest(bytes))),
note: None,
}
}
pub fn check(&self, bytes: &[u8]) -> Result<(), DataFileMismatch> {
let got = Self::of(self.path.clone(), bytes);
if got.bytes == self.bytes && got.sha256 == self.sha256 {
return Ok(());
}
Err(DataFileMismatch {
path: self.path.clone(),
expected_bytes: self.bytes,
expected_sha256: self.sha256,
got_bytes: got.bytes,
got_sha256: got.sha256,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(
"{path}: the manifest signs for {expected_bytes} bytes with SHA-256 \
{expected_sha256}, but {got_bytes} bytes with SHA-256 {got_sha256} were read"
)]
pub struct DataFileMismatch {
pub path: DataFilePath,
pub expected_bytes: u64,
pub expected_sha256: Sha256Hex,
pub got_bytes: u64,
pub got_sha256: Sha256Hex,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(
"not a data file path (relative, `/`-separated, no empty, `.` or `..` \
parts, no backslashes or control characters): {0:?}"
)]
pub struct DataFilePathError(pub String);
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
#[serde(try_from = "String", into = "String")]
pub struct DataFilePath(String);
impl DataFilePath {
pub const MAX_LEN: usize = 1024;
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::str::FromStr for DataFilePath {
type Err = DataFilePathError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let ok = !s.is_empty()
&& s.len() <= Self::MAX_LEN
&& !s.chars().any(|c| c == '\\' || c.is_control())
&& s.split('/')
.all(|part| !part.is_empty() && part != "." && part != "..");
ok.then(|| Self(s.to_string()))
.ok_or_else(|| DataFilePathError(s.to_string()))
}
}
impl TryFrom<String> for DataFilePath {
type Error = DataFilePathError;
fn try_from(s: String) -> Result<Self, Self::Error> {
s.parse()
}
}
impl From<DataFilePath> for String {
fn from(path: DataFilePath) -> Self {
path.0
}
}
impl std::fmt::Display for DataFilePath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for DataFilePath {
fn as_ref(&self) -> &str {
&self.0
}
}
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for DataFilePath {
fn inline_schema() -> bool {
true
}
fn schema_name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("DataFilePath")
}
fn schema_id() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed(concat!(module_path!(), "::DataFilePath"))
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"description": "A file's path in the data repository, relative \
to its root, e.g. \"votes/index.md\"",
})
}
}
#[cfg(test)]
pub(super) mod tests {
use super::super::{Blind, blind_data, non_integer_number};
use super::*;
fn path(s: &str) -> DataFilePath {
s.parse().unwrap()
}
pub(crate) fn manifest() -> DataSet {
let mut readme = DataFile::of(path("votes/README.md"), b"# Votes\n");
readme.note = Some("Read me first".into());
DataSet {
agora_data_set: DATA_SET_VERSION,
title: "Ratification votes".into(),
description: "Every ballot cast on GOV-2026-0001.".into(),
compiled_by: "claude-opus, from the governance log".into(),
repo: "agora-data".into(),
commit: "0123456789abcdef0123456789abcdef01234567".parse().unwrap(),
index: path("votes/README.md"),
files: vec![
readme,
DataFile::of(path("votes/ballots.csv"), b"seat,vote\n"),
],
}
}
#[test]
fn a_manifest_validates_and_round_trips() {
let m = manifest();
assert_eq!(m.validate(), Ok(()));
let value = serde_json::to_value(&m).unwrap();
assert_eq!(value["agora_data_set"], 1);
assert_eq!(value["commit"], "0123456789abcdef0123456789abcdef01234567");
assert_eq!(value["files"][0]["path"], "votes/README.md");
assert_eq!(
value["files"][1]["sha256"],
hex::encode(Sha256::digest(b"seat,vote\n"))
);
assert!(value["files"][1].get("note").is_none());
assert_eq!(non_integer_number(&value), None);
let back: DataSet = serde_json::from_value(value).unwrap();
assert_eq!(back, m);
assert_eq!(m.total_bytes(), 8 + 10);
}
#[test]
fn a_stored_manifest_still_reads() {
let stored = blind_data(
&serde_json::to_value(manifest()).unwrap(),
Blind::from([7; 32]),
)
.unwrap();
let read: DataSet = serde_json::from_value(stored).unwrap();
assert_eq!(read, manifest());
}
#[test]
fn what_a_writer_refuses() {
let refused = |f: &dyn Fn(&mut DataSet)| {
let mut m = manifest();
f(&mut m);
m.validate().unwrap_err()
};
refused(&|m| m.agora_data_set = 2);
refused(&|m| m.compiled_by = " ".into());
refused(&|m| m.files.clear());
refused(&|m| m.files.reverse());
refused(&|m| {
let again = m.files[0].clone();
m.files.insert(0, again);
});
refused(&|m| m.index = path("votes/missing.md"));
refused(&|m| m.files[0].note = Some("two\nlines".into()));
refused(&|m| m.files[0].note = Some("".into()));
refused(&|m| m.files[1].bytes = MAX_DATA_FILE_BYTES + 1);
}
#[test]
fn a_tampered_file_is_refused() {
let m = manifest();
let ballots = m.file(&path("votes/ballots.csv")).unwrap();
assert_eq!(ballots.check(b"seat,vote\n"), Ok(()));
let err = ballots.check(b"seat,vote\nlawyer,yes\n").unwrap_err();
assert_eq!(err.expected_bytes, 10);
assert_eq!(err.got_bytes, 21);
let err = ballots.check(b"seat,VOTE\n").unwrap_err();
assert_eq!(err.got_bytes, err.expected_bytes);
assert_ne!(err.got_sha256, err.expected_sha256);
assert!(err.to_string().contains("votes/ballots.csv"), "{err}");
assert!(m.file(&path("votes/other.csv")).is_none());
}
#[test]
fn attachments_resolve_or_say_why_not() {
let mut m = manifest();
m.files.push(DataFile::of(path("votes/raw/r1.json"), b"{}"));
m.files.sort_by(|a, b| a.path.cmp(&b.path));
let file = |asked: &str| match m.resolve(asked) {
Ok(DataSetTarget::File { file, asked }) => {
(file.path.to_string(), asked)
}
other => panic!("{asked}: {other:?}"),
};
assert_eq!(file("votes/README.md"), ("votes/README.md".into(), None));
assert_eq!(
file("README.md"),
("votes/README.md".into(), Some("README.md".into()))
);
assert_eq!(
file("raw/r1.json"),
("votes/raw/r1.json".into(), Some("raw/r1.json".into()))
);
for exact in [
"./votes/ballots.csv",
"/votes/ballots.csv",
" votes/ballots.csv\n",
] {
assert_eq!(
file(exact),
("votes/ballots.csv".into(), None),
"{exact:?}"
);
}
for empty in ["", "/", "./", " "] {
assert_eq!(
m.resolve(empty).unwrap_err().kind,
DataSetLookupFailure::NotFound,
"{empty:?}"
);
}
let dir = |asked: &str| match m.resolve(asked) {
Ok(DataSetTarget::Directory { prefix, asked }) => (prefix, asked),
other => panic!("{asked}: {other:?}"),
};
assert_eq!(dir("votes/"), ("votes/".into(), None));
assert_eq!(dir("raw/"), ("votes/raw/".into(), Some("raw/".into())));
let err = m.resolve("votes/raw").unwrap_err();
assert_eq!(
err.kind,
DataSetLookupFailure::Directory {
prefix: "votes/raw/".into(),
files: 1,
bytes: 2
}
);
let text = err.to_string();
assert!(text.contains("votes/raw/ is a directory"), "{text}");
assert!(text.contains("attachment=\"votes/raw/\""), "{text}");
assert!(matches!(
m.resolve("raw").unwrap_err().kind,
DataSetLookupFailure::Directory { .. }
));
assert_eq!(
m.resolve("votes/missing.csv").unwrap_err().kind,
DataSetLookupFailure::NotFound
);
assert_eq!(
m.resolve("../x").unwrap_err().kind,
DataSetLookupFailure::NotFound
);
m.files.push(DataFile::of(path("tallies/README.md"), b"t"));
m.files.sort_by(|a, b| a.path.cmp(&b.path));
let err = m.resolve("README.md").unwrap_err();
assert_eq!(
err.kind,
DataSetLookupFailure::Ambiguous(vec![
"tallies/README.md".into(),
"votes/README.md".into()
])
);
m.files
.push(DataFile::of(path("tallies/raw/t.json"), b"{}"));
m.files.sort_by(|a, b| a.path.cmp(&b.path));
assert_eq!(
m.resolve("raw/").unwrap_err().kind,
DataSetLookupFailure::Ambiguous(vec![
"tallies/raw/".into(),
"votes/raw/".into()
])
);
}
#[test]
fn paths_cannot_climb_out() {
for good in ["index.md", "a/b/c.csv", "with space/ΓΌ.txt", ".hidden"] {
assert_eq!(path(good).as_str(), good);
}
for bad in [
"",
"/etc/passwd",
"../secrets",
"a/../../b",
"a//b",
"a/./b",
"a/",
"a\\b",
"a\nb",
"nul\0",
] {
assert!(bad.parse::<DataFilePath>().is_err(), "{bad:?}");
assert!(
serde_json::from_value::<DataFilePath>(serde_json::json!(bad))
.is_err(),
"{bad:?} via serde"
);
}
assert!("a".repeat(1025).parse::<DataFilePath>().is_err());
}
#[test]
fn a_commit_is_twenty_bytes_of_hex() {
assert!("0123456789abcdef".parse::<GitCommitId>().is_err());
assert!(
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
.parse::<GitCommitId>()
.is_err()
);
let id: GitCommitId =
"0123456789ABCDEF0123456789abcdef01234567".parse().unwrap();
assert_eq!(id.to_hex(), "0123456789abcdef0123456789abcdef01234567");
}
}