1use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16use super::{GitCommitId, Sha256Hex};
17
18pub const DATA_SET_VERSION: u32 = 1;
20
21pub const MAX_DATA_FILE_BYTES: u64 =
25 crate::requests::GetContentInput::MAX_COMMENT_BUDGET as u64;
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
33#[cfg_attr(feature = "schemars", schemars(inline))]
34pub struct DataSet {
35 pub agora_data_set: u32,
37 pub title: String,
38 pub description: String,
40 pub compiled_by: String,
42 pub repo: String,
44 pub commit: GitCommitId,
46 pub index: DataFilePath,
48 pub files: Vec<DataFile>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
55#[cfg_attr(feature = "schemars", schemars(inline))]
56pub struct DataFile {
57 pub path: DataFilePath,
59 pub bytes: u64,
61 pub sha256: Sha256Hex,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub note: Option<String>,
66}
67
68impl DataSet {
69 pub fn validate(&self) -> Result<(), &'static str> {
73 if self.agora_data_set != DATA_SET_VERSION {
74 return Err("agora_data_set is not the version this writer writes");
75 }
76 let blank = |s: &str| s.trim().is_empty();
77 if blank(&self.title)
78 || blank(&self.description)
79 || blank(&self.compiled_by)
80 || blank(&self.repo)
81 {
82 return Err(
83 "title, description, compiled_by and repo must all say something",
84 );
85 }
86 if self.files.is_empty() {
87 return Err("a data set lists at least one file");
88 }
89 if self.files.windows(2).any(|w| w[0].path >= w[1].path) {
90 return Err("files must be sorted by path, each listed once");
91 }
92 if self.files.iter().any(|f| f.bytes > MAX_DATA_FILE_BYTES) {
93 return Err(
94 "a file is larger than MAX_DATA_FILE_BYTES (256 KiB); split it",
95 );
96 }
97 if self.file(&self.index).is_none() {
98 return Err("the index must be one of the listed files");
99 }
100 if self.files.iter().any(|f| {
101 f.note
102 .as_deref()
103 .is_some_and(|n| blank(n) || n.contains(['\n', '\r']))
104 }) {
105 return Err("a file's note is one line, or left out");
106 }
107 Ok(())
108 }
109
110 pub fn file(&self, path: &DataFilePath) -> Option<&DataFile> {
112 self.files
113 .binary_search_by(|f| f.path.cmp(path))
114 .ok()
115 .map(|i| &self.files[i])
116 .or_else(|| self.files.iter().find(|f| &f.path == path))
118 }
119
120 pub fn total_bytes(&self) -> u64 {
122 self.files.iter().map(|f| f.bytes).sum()
123 }
124
125 pub fn under(&self, prefix: &str) -> (u64, u64) {
128 self.files
129 .iter()
130 .filter(|f| f.path.as_str().starts_with(prefix))
131 .fold((0, 0), |(n, b), f| (n + 1, b + f.bytes))
132 }
133
134 fn top_directories(&self) -> Vec<&str> {
136 let mut dirs: Vec<&str> = Vec::new();
137 for f in &self.files {
138 if let Some((dir, _)) = f.path.as_str().split_once('/')
139 && !dirs.contains(&dir)
140 {
141 dirs.push(dir);
142 }
143 }
144 dirs
145 }
146
147 pub fn resolve(
155 &self,
156 asked: &str,
157 ) -> Result<DataSetTarget<'_>, DataSetLookupError> {
158 let trimmed = asked.trim();
159 let path = trimmed.strip_prefix("./").unwrap_or(trimmed);
160 let path = path.trim_start_matches('/');
161 let lookup_error = |kind| DataSetLookupError {
162 asked: asked.to_string(),
163 kind,
164 };
165 if path.is_empty() {
166 return Err(lookup_error(DataSetLookupFailure::NotFound));
167 }
168 let tops = self.top_directories();
169 let file_at =
170 |p: &str| p.parse().ok().and_then(|p: DataFilePath| self.file(&p));
171 let directory = |prefix: String| {
172 let (files, bytes) = self.under(&prefix);
173 (files > 0).then_some((prefix, files, bytes))
174 };
175 let one = |mut found: Vec<String>| match found.len() {
177 1 => Ok(found.pop()),
178 0 => Ok(None),
179 _ => Err(lookup_error(DataSetLookupFailure::Ambiguous(found))),
180 };
181 if path.ends_with('/') {
182 if directory(path.to_string()).is_some() {
183 return Ok(DataSetTarget::Directory {
184 prefix: path.to_string(),
185 asked: None,
186 });
187 }
188 let found = tops
189 .iter()
190 .filter_map(|top| directory(format!("{top}/{path}")))
191 .map(|(prefix, ..)| prefix)
192 .collect();
193 return match one(found)? {
194 Some(prefix) => Ok(DataSetTarget::Directory {
195 asked: Some(asked.to_string()),
196 prefix,
197 }),
198 None => Err(lookup_error(DataSetLookupFailure::NotFound)),
199 };
200 }
201 if let Some(file) = file_at(path) {
202 return Ok(DataSetTarget::File { file, asked: None });
203 }
204 let not_a_file = |(prefix, files, bytes)| {
205 lookup_error(DataSetLookupFailure::Directory {
206 prefix,
207 files,
208 bytes,
209 })
210 };
211 if let Some(dir) = directory(format!("{path}/")) {
212 return Err(not_a_file(dir));
213 }
214 let found = tops
215 .iter()
216 .map(|top| format!("{top}/{path}"))
217 .filter(|p| file_at(p).is_some())
218 .collect();
219 if let Some(full) = one(found)?
220 && let Some(file) = file_at(&full)
221 {
222 return Ok(DataSetTarget::File {
223 file,
224 asked: Some(asked.to_string()),
225 });
226 }
227 let dirs: Vec<_> = tops
228 .iter()
229 .filter_map(|top| directory(format!("{top}/{path}/")))
230 .collect();
231 match dirs.len() {
232 0 => Err(lookup_error(DataSetLookupFailure::NotFound)),
233 1 => Err(not_a_file(dirs.into_iter().next().expect("one"))),
234 _ => Err(lookup_error(DataSetLookupFailure::Ambiguous(
235 dirs.into_iter().map(|(prefix, ..)| prefix).collect(),
236 ))),
237 }
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
243pub enum DataSetTarget<'a> {
244 File {
246 file: &'a DataFile,
247 asked: Option<String>,
249 },
250 Directory {
252 prefix: String,
253 asked: Option<String>,
255 },
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
261#[error("attachment={asked:?}: {kind}")]
262pub struct DataSetLookupError {
263 pub asked: String,
264 pub kind: DataSetLookupFailure,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
269pub enum DataSetLookupFailure {
270 #[error(
272 "{prefix} is a directory of this data set ({files} files, {bytes} \
273 bytes), not a file. List it with attachment=\"{prefix}\" (a directory \
274 ends in `/`), or read one of its files by its path."
275 )]
276 Directory {
277 prefix: String,
278 files: u64,
279 bytes: u64,
280 },
281 #[error(
283 "that path is under more than one of this data set's top directories: \
284 {}. Ask for one of them by its full path.",
285 .0.join(", ")
286 )]
287 Ambiguous(Vec<String>),
288 #[error(
289 "no file or directory of this data set is at that path. Paths are \
290 relative to the data repository's root (a path relative to a \
291 directory at the root also works when only one directory holds it); \
292 a directory ends in `/`."
293 )]
294 NotFound,
295}
296
297impl DataFile {
298 pub fn of(path: DataFilePath, bytes: &[u8]) -> Self {
300 Self {
301 path,
302 bytes: bytes.len() as u64,
303 sha256: Sha256Hex::from(<[u8; 32]>::from(Sha256::digest(bytes))),
304 note: None,
305 }
306 }
307
308 pub fn check(&self, bytes: &[u8]) -> Result<(), DataFileMismatch> {
310 let got = Self::of(self.path.clone(), bytes);
311 if got.bytes == self.bytes && got.sha256 == self.sha256 {
312 return Ok(());
313 }
314 Err(DataFileMismatch {
315 path: self.path.clone(),
316 expected_bytes: self.bytes,
317 expected_sha256: self.sha256,
318 got_bytes: got.bytes,
319 got_sha256: got.sha256,
320 })
321 }
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
327#[error(
328 "{path}: the manifest signs for {expected_bytes} bytes with SHA-256 \
329 {expected_sha256}, but {got_bytes} bytes with SHA-256 {got_sha256} were read"
330)]
331pub struct DataFileMismatch {
332 pub path: DataFilePath,
333 pub expected_bytes: u64,
334 pub expected_sha256: Sha256Hex,
335 pub got_bytes: u64,
336 pub got_sha256: Sha256Hex,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
341#[error(
342 "not a data file path (relative, `/`-separated, no empty, `.` or `..` \
343 parts, no backslashes or control characters): {0:?}"
344)]
345pub struct DataFilePathError(pub String);
346
347#[derive(
350 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
351)]
352#[serde(try_from = "String", into = "String")]
353pub struct DataFilePath(String);
354
355impl DataFilePath {
356 pub const MAX_LEN: usize = 1024;
358
359 pub fn as_str(&self) -> &str {
360 &self.0
361 }
362}
363
364impl std::str::FromStr for DataFilePath {
365 type Err = DataFilePathError;
366
367 fn from_str(s: &str) -> Result<Self, Self::Err> {
368 let ok = !s.is_empty()
369 && s.len() <= Self::MAX_LEN
370 && !s.chars().any(|c| c == '\\' || c.is_control())
371 && s.split('/')
372 .all(|part| !part.is_empty() && part != "." && part != "..");
373 ok.then(|| Self(s.to_string()))
374 .ok_or_else(|| DataFilePathError(s.to_string()))
375 }
376}
377
378impl TryFrom<String> for DataFilePath {
379 type Error = DataFilePathError;
380
381 fn try_from(s: String) -> Result<Self, Self::Error> {
382 s.parse()
383 }
384}
385
386impl From<DataFilePath> for String {
387 fn from(path: DataFilePath) -> Self {
388 path.0
389 }
390}
391
392impl std::fmt::Display for DataFilePath {
393 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394 f.write_str(&self.0)
395 }
396}
397
398impl AsRef<str> for DataFilePath {
399 fn as_ref(&self) -> &str {
400 &self.0
401 }
402}
403
404#[cfg(feature = "schemars")]
406impl schemars::JsonSchema for DataFilePath {
407 fn inline_schema() -> bool {
408 true
409 }
410
411 fn schema_name() -> std::borrow::Cow<'static, str> {
412 std::borrow::Cow::Borrowed("DataFilePath")
413 }
414
415 fn schema_id() -> std::borrow::Cow<'static, str> {
416 std::borrow::Cow::Borrowed(concat!(module_path!(), "::DataFilePath"))
417 }
418
419 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
420 schemars::json_schema!({
421 "type": "string",
422 "description": "A file's path in the data repository, relative \
423 to its root, e.g. \"votes/index.md\"",
424 })
425 }
426}
427
428#[cfg(test)]
429pub(super) mod tests {
430 use super::super::{Blind, blind_data, non_integer_number};
431 use super::*;
432
433 fn path(s: &str) -> DataFilePath {
434 s.parse().unwrap()
435 }
436
437 pub(crate) fn manifest() -> DataSet {
438 let mut readme = DataFile::of(path("votes/README.md"), b"# Votes\n");
439 readme.note = Some("Read me first".into());
440 DataSet {
441 agora_data_set: DATA_SET_VERSION,
442 title: "Ratification votes".into(),
443 description: "Every ballot cast on GOV-2026-0001.".into(),
444 compiled_by: "claude-opus, from the governance log".into(),
445 repo: "agora-data".into(),
446 commit: "0123456789abcdef0123456789abcdef01234567".parse().unwrap(),
447 index: path("votes/README.md"),
448 files: vec![
450 readme,
451 DataFile::of(path("votes/ballots.csv"), b"seat,vote\n"),
452 ],
453 }
454 }
455
456 #[test]
457 fn a_manifest_validates_and_round_trips() {
458 let m = manifest();
459 assert_eq!(m.validate(), Ok(()));
460 let value = serde_json::to_value(&m).unwrap();
461 assert_eq!(value["agora_data_set"], 1);
462 assert_eq!(value["commit"], "0123456789abcdef0123456789abcdef01234567");
463 assert_eq!(value["files"][0]["path"], "votes/README.md");
464 assert_eq!(
465 value["files"][1]["sha256"],
466 hex::encode(Sha256::digest(b"seat,vote\n"))
468 );
469 assert!(value["files"][1].get("note").is_none());
470 assert_eq!(non_integer_number(&value), None);
471 let back: DataSet = serde_json::from_value(value).unwrap();
472 assert_eq!(back, m);
473 assert_eq!(m.total_bytes(), 8 + 10);
474 }
475
476 #[test]
477 fn a_stored_manifest_still_reads() {
478 let stored = blind_data(
479 &serde_json::to_value(manifest()).unwrap(),
480 Blind::from([7; 32]),
481 )
482 .unwrap();
483 let read: DataSet = serde_json::from_value(stored).unwrap();
484 assert_eq!(read, manifest());
485 }
486
487 #[test]
488 fn what_a_writer_refuses() {
489 let refused = |f: &dyn Fn(&mut DataSet)| {
490 let mut m = manifest();
491 f(&mut m);
492 m.validate().unwrap_err()
493 };
494 refused(&|m| m.agora_data_set = 2);
495 refused(&|m| m.compiled_by = " ".into());
496 refused(&|m| m.files.clear());
497 refused(&|m| m.files.reverse());
498 refused(&|m| {
499 let again = m.files[0].clone();
500 m.files.insert(0, again);
501 });
502 refused(&|m| m.index = path("votes/missing.md"));
503 refused(&|m| m.files[0].note = Some("two\nlines".into()));
504 refused(&|m| m.files[0].note = Some("".into()));
505 refused(&|m| m.files[1].bytes = MAX_DATA_FILE_BYTES + 1);
506 }
507
508 #[test]
509 fn a_tampered_file_is_refused() {
510 let m = manifest();
511 let ballots = m.file(&path("votes/ballots.csv")).unwrap();
512 assert_eq!(ballots.check(b"seat,vote\n"), Ok(()));
513 let err = ballots.check(b"seat,vote\nlawyer,yes\n").unwrap_err();
514 assert_eq!(err.expected_bytes, 10);
515 assert_eq!(err.got_bytes, 21);
516 let err = ballots.check(b"seat,VOTE\n").unwrap_err();
518 assert_eq!(err.got_bytes, err.expected_bytes);
519 assert_ne!(err.got_sha256, err.expected_sha256);
520 assert!(err.to_string().contains("votes/ballots.csv"), "{err}");
521 assert!(m.file(&path("votes/other.csv")).is_none());
522 }
523
524 #[test]
528 fn attachments_resolve_or_say_why_not() {
529 let mut m = manifest();
530 m.files.push(DataFile::of(path("votes/raw/r1.json"), b"{}"));
531 m.files.sort_by(|a, b| a.path.cmp(&b.path));
532 let file = |asked: &str| match m.resolve(asked) {
533 Ok(DataSetTarget::File { file, asked }) => {
534 (file.path.to_string(), asked)
535 }
536 other => panic!("{asked}: {other:?}"),
537 };
538 assert_eq!(file("votes/README.md"), ("votes/README.md".into(), None));
539 assert_eq!(
541 file("README.md"),
542 ("votes/README.md".into(), Some("README.md".into()))
543 );
544 assert_eq!(
545 file("raw/r1.json"),
546 ("votes/raw/r1.json".into(), Some("raw/r1.json".into()))
547 );
548 for exact in [
550 "./votes/ballots.csv",
551 "/votes/ballots.csv",
552 " votes/ballots.csv\n",
553 ] {
554 assert_eq!(
555 file(exact),
556 ("votes/ballots.csv".into(), None),
557 "{exact:?}"
558 );
559 }
560 for empty in ["", "/", "./", " "] {
561 assert_eq!(
562 m.resolve(empty).unwrap_err().kind,
563 DataSetLookupFailure::NotFound,
564 "{empty:?}"
565 );
566 }
567 let dir = |asked: &str| match m.resolve(asked) {
568 Ok(DataSetTarget::Directory { prefix, asked }) => (prefix, asked),
569 other => panic!("{asked}: {other:?}"),
570 };
571 assert_eq!(dir("votes/"), ("votes/".into(), None));
572 assert_eq!(dir("raw/"), ("votes/raw/".into(), Some("raw/".into())));
573 let err = m.resolve("votes/raw").unwrap_err();
575 assert_eq!(
576 err.kind,
577 DataSetLookupFailure::Directory {
578 prefix: "votes/raw/".into(),
579 files: 1,
580 bytes: 2
581 }
582 );
583 let text = err.to_string();
584 assert!(text.contains("votes/raw/ is a directory"), "{text}");
585 assert!(text.contains("attachment=\"votes/raw/\""), "{text}");
586 assert!(matches!(
587 m.resolve("raw").unwrap_err().kind,
588 DataSetLookupFailure::Directory { .. }
589 ));
590 assert_eq!(
591 m.resolve("votes/missing.csv").unwrap_err().kind,
592 DataSetLookupFailure::NotFound
593 );
594 assert_eq!(
595 m.resolve("../x").unwrap_err().kind,
596 DataSetLookupFailure::NotFound
597 );
598 m.files.push(DataFile::of(path("tallies/README.md"), b"t"));
600 m.files.sort_by(|a, b| a.path.cmp(&b.path));
601 let err = m.resolve("README.md").unwrap_err();
602 assert_eq!(
603 err.kind,
604 DataSetLookupFailure::Ambiguous(vec![
605 "tallies/README.md".into(),
606 "votes/README.md".into()
607 ])
608 );
609 m.files
610 .push(DataFile::of(path("tallies/raw/t.json"), b"{}"));
611 m.files.sort_by(|a, b| a.path.cmp(&b.path));
612 assert_eq!(
613 m.resolve("raw/").unwrap_err().kind,
614 DataSetLookupFailure::Ambiguous(vec![
615 "tallies/raw/".into(),
616 "votes/raw/".into()
617 ])
618 );
619 }
620
621 #[test]
622 fn paths_cannot_climb_out() {
623 for good in ["index.md", "a/b/c.csv", "with space/ΓΌ.txt", ".hidden"] {
624 assert_eq!(path(good).as_str(), good);
625 }
626 for bad in [
627 "",
628 "/etc/passwd",
629 "../secrets",
630 "a/../../b",
631 "a//b",
632 "a/./b",
633 "a/",
634 "a\\b",
635 "a\nb",
636 "nul\0",
637 ] {
638 assert!(bad.parse::<DataFilePath>().is_err(), "{bad:?}");
639 assert!(
640 serde_json::from_value::<DataFilePath>(serde_json::json!(bad))
641 .is_err(),
642 "{bad:?} via serde"
643 );
644 }
645 assert!("a".repeat(1025).parse::<DataFilePath>().is_err());
646 }
647
648 #[test]
649 fn a_commit_is_twenty_bytes_of_hex() {
650 assert!("0123456789abcdef".parse::<GitCommitId>().is_err());
651 assert!(
652 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
653 .parse::<GitCommitId>()
654 .is_err()
655 );
656 let id: GitCommitId =
657 "0123456789ABCDEF0123456789abcdef01234567".parse().unwrap();
658 assert_eq!(id.to_hex(), "0123456789abcdef0123456789abcdef01234567");
659 }
660}