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_WHOLE_READ_BYTES: u64 =
25 crate::requests::GetContentInput::MAX_COMMENT_BUDGET as u64;
26
27pub const MAX_DATA_FILE_BYTES: u64 = 16 * 1024 * 1024;
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
45#[cfg_attr(feature = "schemars", schemars(inline))]
46pub struct DataSet {
47 pub agora_data_set: u32,
49 pub title: String,
50 pub description: String,
52 pub compiled_by: String,
54 pub repo: DataRepoName,
58 pub commit: GitCommitId,
60 pub index: DataFilePath,
62 pub files: Vec<DataFile>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
69#[cfg_attr(feature = "schemars", schemars(inline))]
70pub struct DataFile {
71 pub path: DataFilePath,
73 pub bytes: u64,
75 pub sha256: Sha256Hex,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub note: Option<String>,
80}
81
82impl DataSet {
83 pub fn validate(&self) -> Result<(), &'static str> {
87 if self.agora_data_set != DATA_SET_VERSION {
88 return Err("agora_data_set is not the version this writer writes");
89 }
90 let blank = |s: &str| s.trim().is_empty();
91 if blank(&self.title)
92 || blank(&self.description)
93 || blank(&self.compiled_by)
94 {
95 return Err(
96 "title, description and compiled_by must all say something",
97 );
98 }
99 if self.files.is_empty() {
100 return Err("a data set lists at least one file");
101 }
102 if self.files.windows(2).any(|w| w[0].path >= w[1].path) {
103 return Err("files must be sorted by path, each listed once");
104 }
105 if self.files.iter().any(|f| f.bytes > MAX_DATA_FILE_BYTES) {
106 return Err(
107 "a file is larger than MAX_DATA_FILE_BYTES (16 MiB); split it",
108 );
109 }
110 if self.file(&self.index).is_none() {
111 return Err("the index must be one of the listed files");
112 }
113 if self.files.iter().any(|f| {
114 f.note
115 .as_deref()
116 .is_some_and(|n| blank(n) || n.contains(['\n', '\r']))
117 }) {
118 return Err("a file's note is one line, or left out");
119 }
120 Ok(())
121 }
122
123 pub fn file(&self, path: &DataFilePath) -> Option<&DataFile> {
125 self.files
126 .binary_search_by(|f| f.path.cmp(path))
127 .ok()
128 .map(|i| &self.files[i])
129 .or_else(|| self.files.iter().find(|f| &f.path == path))
131 }
132
133 pub fn total_bytes(&self) -> u64 {
135 self.files.iter().map(|f| f.bytes).sum()
136 }
137
138 pub fn under(&self, prefix: &str) -> (u64, u64) {
141 self.files
142 .iter()
143 .filter(|f| f.path.as_str().starts_with(prefix))
144 .fold((0, 0), |(n, b), f| (n + 1, b + f.bytes))
145 }
146
147 fn top_directories(&self) -> Vec<&str> {
149 let mut dirs: Vec<&str> = Vec::new();
150 for f in &self.files {
151 if let Some((dir, _)) = f.path.as_str().split_once('/')
152 && !dirs.contains(&dir)
153 {
154 dirs.push(dir);
155 }
156 }
157 dirs
158 }
159
160 pub fn resolve(
168 &self,
169 asked: &str,
170 ) -> Result<DataSetTarget<'_>, DataSetLookupError> {
171 let trimmed = asked.trim();
172 let path = trimmed.strip_prefix("./").unwrap_or(trimmed);
173 let path = path.trim_start_matches('/');
174 let lookup_error = |kind| DataSetLookupError {
175 asked: asked.to_string(),
176 kind,
177 };
178 if path.is_empty() {
179 return Err(lookup_error(DataSetLookupFailure::NotFound));
180 }
181 let tops = self.top_directories();
182 let file_at =
183 |p: &str| p.parse().ok().and_then(|p: DataFilePath| self.file(&p));
184 let directory = |prefix: String| {
185 let (files, bytes) = self.under(&prefix);
186 (files > 0).then_some((prefix, files, bytes))
187 };
188 let one = |mut found: Vec<String>| match found.len() {
190 1 => Ok(found.pop()),
191 0 => Ok(None),
192 _ => Err(lookup_error(DataSetLookupFailure::Ambiguous(found))),
193 };
194 if path.ends_with('/') {
195 if directory(path.to_string()).is_some() {
196 return Ok(DataSetTarget::Directory {
197 prefix: path.to_string(),
198 asked: None,
199 });
200 }
201 let found = tops
202 .iter()
203 .filter_map(|top| directory(format!("{top}/{path}")))
204 .map(|(prefix, ..)| prefix)
205 .collect();
206 return match one(found)? {
207 Some(prefix) => Ok(DataSetTarget::Directory {
208 asked: Some(asked.to_string()),
209 prefix,
210 }),
211 None => Err(lookup_error(DataSetLookupFailure::NotFound)),
212 };
213 }
214 if let Some(file) = file_at(path) {
215 return Ok(DataSetTarget::File { file, asked: None });
216 }
217 let not_a_file = |(prefix, files, bytes)| {
218 lookup_error(DataSetLookupFailure::Directory {
219 prefix,
220 files,
221 bytes,
222 })
223 };
224 if let Some(dir) = directory(format!("{path}/")) {
225 return Err(not_a_file(dir));
226 }
227 let found = tops
228 .iter()
229 .map(|top| format!("{top}/{path}"))
230 .filter(|p| file_at(p).is_some())
231 .collect();
232 if let Some(full) = one(found)?
233 && let Some(file) = file_at(&full)
234 {
235 return Ok(DataSetTarget::File {
236 file,
237 asked: Some(asked.to_string()),
238 });
239 }
240 let dirs: Vec<_> = tops
241 .iter()
242 .filter_map(|top| directory(format!("{top}/{path}/")))
243 .collect();
244 match dirs.len() {
245 0 => Err(lookup_error(DataSetLookupFailure::NotFound)),
246 1 => Err(not_a_file(dirs.into_iter().next().expect("one"))),
247 _ => Err(lookup_error(DataSetLookupFailure::Ambiguous(
248 dirs.into_iter().map(|(prefix, ..)| prefix).collect(),
249 ))),
250 }
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum DataSetTarget<'a> {
257 File {
259 file: &'a DataFile,
260 asked: Option<String>,
262 },
263 Directory {
265 prefix: String,
266 asked: Option<String>,
268 },
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
274#[error("attachment={asked:?}: {kind}")]
275pub struct DataSetLookupError {
276 pub asked: String,
277 pub kind: DataSetLookupFailure,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
282pub enum DataSetLookupFailure {
283 #[error(
285 "{prefix} is a directory of this data set ({files} files, {bytes} \
286 bytes), not a file. List it with attachment=\"{prefix}\" (a directory \
287 ends in `/`), or read one of its files by its path."
288 )]
289 Directory {
290 prefix: String,
291 files: u64,
292 bytes: u64,
293 },
294 #[error(
296 "that path is under more than one of this data set's top directories: \
297 {}. Ask for one of them by its full path.",
298 .0.join(", ")
299 )]
300 Ambiguous(Vec<String>),
301 #[error(
302 "no file or directory of this data set is at that path. Paths are \
303 relative to the data repository's root (a path relative to a \
304 directory at the root also works when only one directory holds it); \
305 a directory ends in `/`."
306 )]
307 NotFound,
308}
309
310impl DataFile {
311 pub fn of(path: DataFilePath, bytes: &[u8]) -> Self {
313 Self {
314 path,
315 bytes: bytes.len() as u64,
316 sha256: Sha256Hex::from(<[u8; 32]>::from(Sha256::digest(bytes))),
317 note: None,
318 }
319 }
320
321 pub fn check(&self, bytes: &[u8]) -> Result<(), DataFileMismatch> {
323 let got = Self::of(self.path.clone(), bytes);
324 if got.bytes == self.bytes && got.sha256 == self.sha256 {
325 return Ok(());
326 }
327 Err(DataFileMismatch {
328 path: self.path.clone(),
329 expected_bytes: self.bytes,
330 expected_sha256: self.sha256,
331 got_bytes: got.bytes,
332 got_sha256: got.sha256,
333 })
334 }
335}
336
337#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
340#[error(
341 "{path}: the manifest signs for {expected_bytes} bytes with SHA-256 \
342 {expected_sha256}, but {got_bytes} bytes with SHA-256 {got_sha256} were read"
343)]
344pub struct DataFileMismatch {
345 pub path: DataFilePath,
346 pub expected_bytes: u64,
347 pub expected_sha256: Sha256Hex,
348 pub got_bytes: u64,
349 pub got_sha256: Sha256Hex,
350}
351
352#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
354#[error(
355 "not a data repository name (1 to 63 lowercase letters, digits and `-`, \
356 starting with a letter or digit): {0:?}"
357)]
358pub struct DataRepoNameError(pub String);
359
360#[derive(
366 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
367)]
368#[serde(try_from = "String", into = "String")]
369pub struct DataRepoName(String);
370
371impl DataRepoName {
372 pub const MAX_LEN: usize = 63;
374
375 pub fn as_str(&self) -> &str {
376 &self.0
377 }
378
379 pub fn dir_name(&self) -> String {
381 format!("{}.git", self.0)
382 }
383}
384
385impl std::str::FromStr for DataRepoName {
386 type Err = DataRepoNameError;
387
388 fn from_str(s: &str) -> Result<Self, Self::Err> {
389 let ok = (1..=Self::MAX_LEN).contains(&s.len())
390 && s.bytes().all(|b| {
391 b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'
392 })
393 && !s.starts_with('-');
394 ok.then(|| Self(s.to_string()))
395 .ok_or_else(|| DataRepoNameError(s.to_string()))
396 }
397}
398
399impl TryFrom<String> for DataRepoName {
400 type Error = DataRepoNameError;
401
402 fn try_from(s: String) -> Result<Self, Self::Error> {
403 s.parse()
404 }
405}
406
407impl From<DataRepoName> for String {
408 fn from(name: DataRepoName) -> Self {
409 name.0
410 }
411}
412
413impl std::fmt::Display for DataRepoName {
414 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
415 f.write_str(&self.0)
416 }
417}
418
419impl AsRef<str> for DataRepoName {
420 fn as_ref(&self) -> &str {
421 &self.0
422 }
423}
424
425#[cfg(feature = "schemars")]
426impl schemars::JsonSchema for DataRepoName {
427 fn inline_schema() -> bool {
428 true
429 }
430
431 fn schema_name() -> std::borrow::Cow<'static, str> {
432 std::borrow::Cow::Borrowed("DataRepoName")
433 }
434
435 fn schema_id() -> std::borrow::Cow<'static, str> {
436 std::borrow::Cow::Borrowed(concat!(module_path!(), "::DataRepoName"))
437 }
438
439 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
440 schemars::json_schema!({
441 "type": "string",
442 "description": "A data repository's name, e.g. \"agora-data\"",
443 })
444 }
445}
446
447#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
449#[error(
450 "not a data file path (relative, `/`-separated, no empty, `.` or `..` \
451 parts, no backslashes or control characters): {0:?}"
452)]
453pub struct DataFilePathError(pub String);
454
455#[derive(
458 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
459)]
460#[serde(try_from = "String", into = "String")]
461pub struct DataFilePath(String);
462
463impl DataFilePath {
464 pub const MAX_LEN: usize = 1024;
466
467 pub fn as_str(&self) -> &str {
468 &self.0
469 }
470}
471
472impl std::str::FromStr for DataFilePath {
473 type Err = DataFilePathError;
474
475 fn from_str(s: &str) -> Result<Self, Self::Err> {
476 let ok = !s.is_empty()
477 && s.len() <= Self::MAX_LEN
478 && !s.chars().any(|c| c == '\\' || c.is_control())
479 && s.split('/')
480 .all(|part| !part.is_empty() && part != "." && part != "..");
481 ok.then(|| Self(s.to_string()))
482 .ok_or_else(|| DataFilePathError(s.to_string()))
483 }
484}
485
486impl TryFrom<String> for DataFilePath {
487 type Error = DataFilePathError;
488
489 fn try_from(s: String) -> Result<Self, Self::Error> {
490 s.parse()
491 }
492}
493
494impl From<DataFilePath> for String {
495 fn from(path: DataFilePath) -> Self {
496 path.0
497 }
498}
499
500impl std::fmt::Display for DataFilePath {
501 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
502 f.write_str(&self.0)
503 }
504}
505
506impl AsRef<str> for DataFilePath {
507 fn as_ref(&self) -> &str {
508 &self.0
509 }
510}
511
512#[cfg(feature = "schemars")]
514impl schemars::JsonSchema for DataFilePath {
515 fn inline_schema() -> bool {
516 true
517 }
518
519 fn schema_name() -> std::borrow::Cow<'static, str> {
520 std::borrow::Cow::Borrowed("DataFilePath")
521 }
522
523 fn schema_id() -> std::borrow::Cow<'static, str> {
524 std::borrow::Cow::Borrowed(concat!(module_path!(), "::DataFilePath"))
525 }
526
527 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
528 schemars::json_schema!({
529 "type": "string",
530 "description": "A file's path in the data repository, relative \
531 to its root, e.g. \"votes/index.md\"",
532 })
533 }
534}
535
536#[cfg(test)]
537pub(super) mod tests {
538 use super::super::{Blind, blind_data, non_integer_number};
539 use super::*;
540
541 fn path(s: &str) -> DataFilePath {
542 s.parse().unwrap()
543 }
544
545 pub(crate) fn manifest() -> DataSet {
546 let mut readme = DataFile::of(path("votes/README.md"), b"# Votes\n");
547 readme.note = Some("Read me first".into());
548 DataSet {
549 agora_data_set: DATA_SET_VERSION,
550 title: "Ratification votes".into(),
551 description: "Every ballot cast on GOV-2026-0001.".into(),
552 compiled_by: "claude-opus, from the governance log".into(),
553 repo: "agora-data".parse().unwrap(),
554 commit: "0123456789abcdef0123456789abcdef01234567".parse().unwrap(),
555 index: path("votes/README.md"),
556 files: vec![
558 readme,
559 DataFile::of(path("votes/ballots.csv"), b"seat,vote\n"),
560 ],
561 }
562 }
563
564 #[test]
565 fn a_manifest_validates_and_round_trips() {
566 let m = manifest();
567 assert_eq!(m.validate(), Ok(()));
568 let value = serde_json::to_value(&m).unwrap();
569 assert_eq!(value["agora_data_set"], 1);
570 assert_eq!(value["commit"], "0123456789abcdef0123456789abcdef01234567");
571 assert_eq!(value["files"][0]["path"], "votes/README.md");
572 assert_eq!(
573 value["files"][1]["sha256"],
574 hex::encode(Sha256::digest(b"seat,vote\n"))
576 );
577 assert!(value["files"][1].get("note").is_none());
578 assert_eq!(non_integer_number(&value), None);
579 let back: DataSet = serde_json::from_value(value).unwrap();
580 assert_eq!(back, m);
581 assert_eq!(m.total_bytes(), 8 + 10);
582 }
583
584 #[test]
585 fn a_stored_manifest_still_reads() {
586 let stored = blind_data(
587 &serde_json::to_value(manifest()).unwrap(),
588 Blind::from([7; 32]),
589 )
590 .unwrap();
591 let read: DataSet = serde_json::from_value(stored).unwrap();
592 assert_eq!(read, manifest());
593 }
594
595 #[test]
596 fn what_a_writer_refuses() {
597 let refused = |f: &dyn Fn(&mut DataSet)| {
598 let mut m = manifest();
599 f(&mut m);
600 m.validate().unwrap_err()
601 };
602 refused(&|m| m.agora_data_set = 2);
603 refused(&|m| m.compiled_by = " ".into());
604 refused(&|m| m.files.clear());
605 refused(&|m| m.files.reverse());
606 refused(&|m| {
607 let again = m.files[0].clone();
608 m.files.insert(0, again);
609 });
610 refused(&|m| m.index = path("votes/missing.md"));
611 refused(&|m| m.files[0].note = Some("two\nlines".into()));
612 refused(&|m| m.files[0].note = Some("".into()));
613 refused(&|m| m.files[1].bytes = MAX_DATA_FILE_BYTES + 1);
614 }
615
616 #[test]
617 fn a_tampered_file_is_refused() {
618 let m = manifest();
619 let ballots = m.file(&path("votes/ballots.csv")).unwrap();
620 assert_eq!(ballots.check(b"seat,vote\n"), Ok(()));
621 let err = ballots.check(b"seat,vote\nlawyer,yes\n").unwrap_err();
622 assert_eq!(err.expected_bytes, 10);
623 assert_eq!(err.got_bytes, 21);
624 let err = ballots.check(b"seat,VOTE\n").unwrap_err();
626 assert_eq!(err.got_bytes, err.expected_bytes);
627 assert_ne!(err.got_sha256, err.expected_sha256);
628 assert!(err.to_string().contains("votes/ballots.csv"), "{err}");
629 assert!(m.file(&path("votes/other.csv")).is_none());
630 }
631
632 #[test]
636 fn attachments_resolve_or_say_why_not() {
637 let mut m = manifest();
638 m.files.push(DataFile::of(path("votes/raw/r1.json"), b"{}"));
639 m.files.sort_by(|a, b| a.path.cmp(&b.path));
640 let file = |asked: &str| match m.resolve(asked) {
641 Ok(DataSetTarget::File { file, asked }) => {
642 (file.path.to_string(), asked)
643 }
644 other => panic!("{asked}: {other:?}"),
645 };
646 assert_eq!(file("votes/README.md"), ("votes/README.md".into(), None));
647 assert_eq!(
649 file("README.md"),
650 ("votes/README.md".into(), Some("README.md".into()))
651 );
652 assert_eq!(
653 file("raw/r1.json"),
654 ("votes/raw/r1.json".into(), Some("raw/r1.json".into()))
655 );
656 for exact in [
658 "./votes/ballots.csv",
659 "/votes/ballots.csv",
660 " votes/ballots.csv\n",
661 ] {
662 assert_eq!(
663 file(exact),
664 ("votes/ballots.csv".into(), None),
665 "{exact:?}"
666 );
667 }
668 for empty in ["", "/", "./", " "] {
669 assert_eq!(
670 m.resolve(empty).unwrap_err().kind,
671 DataSetLookupFailure::NotFound,
672 "{empty:?}"
673 );
674 }
675 let dir = |asked: &str| match m.resolve(asked) {
676 Ok(DataSetTarget::Directory { prefix, asked }) => (prefix, asked),
677 other => panic!("{asked}: {other:?}"),
678 };
679 assert_eq!(dir("votes/"), ("votes/".into(), None));
680 assert_eq!(dir("raw/"), ("votes/raw/".into(), Some("raw/".into())));
681 let err = m.resolve("votes/raw").unwrap_err();
683 assert_eq!(
684 err.kind,
685 DataSetLookupFailure::Directory {
686 prefix: "votes/raw/".into(),
687 files: 1,
688 bytes: 2
689 }
690 );
691 let text = err.to_string();
692 assert!(text.contains("votes/raw/ is a directory"), "{text}");
693 assert!(text.contains("attachment=\"votes/raw/\""), "{text}");
694 assert!(matches!(
695 m.resolve("raw").unwrap_err().kind,
696 DataSetLookupFailure::Directory { .. }
697 ));
698 assert_eq!(
699 m.resolve("votes/missing.csv").unwrap_err().kind,
700 DataSetLookupFailure::NotFound
701 );
702 assert_eq!(
703 m.resolve("../x").unwrap_err().kind,
704 DataSetLookupFailure::NotFound
705 );
706 m.files.push(DataFile::of(path("tallies/README.md"), b"t"));
708 m.files.sort_by(|a, b| a.path.cmp(&b.path));
709 let err = m.resolve("README.md").unwrap_err();
710 assert_eq!(
711 err.kind,
712 DataSetLookupFailure::Ambiguous(vec![
713 "tallies/README.md".into(),
714 "votes/README.md".into()
715 ])
716 );
717 m.files
718 .push(DataFile::of(path("tallies/raw/t.json"), b"{}"));
719 m.files.sort_by(|a, b| a.path.cmp(&b.path));
720 assert_eq!(
721 m.resolve("raw/").unwrap_err().kind,
722 DataSetLookupFailure::Ambiguous(vec![
723 "tallies/raw/".into(),
724 "votes/raw/".into()
725 ])
726 );
727 }
728
729 #[test]
732 fn repo_names_are_one_plain_word() {
733 for good in ["agora-data", "a", "witness-2026-10-18", &"a".repeat(63)] {
734 assert_eq!(good.parse::<DataRepoName>().unwrap().as_str(), good);
735 }
736 assert_eq!(
737 "agora-data".parse::<DataRepoName>().unwrap().dir_name(),
738 "agora-data.git"
739 );
740 for bad in [
741 "",
742 "-a",
743 "Agora",
744 "a.git",
745 "..",
746 "a/b",
747 "a\\b",
748 "a b",
749 "a_b",
750 "/etc",
751 &"a".repeat(64),
752 ] {
753 assert!(bad.parse::<DataRepoName>().is_err(), "{bad:?}");
754 }
755 let mut m = serde_json::to_value(manifest()).unwrap();
756 m["repo"] = "../secrets".into();
757 assert!(serde_json::from_value::<DataSet>(m).is_err());
758 }
759
760 #[test]
761 fn paths_cannot_climb_out() {
762 for good in ["index.md", "a/b/c.csv", "with space/ΓΌ.txt", ".hidden"] {
763 assert_eq!(path(good).as_str(), good);
764 }
765 for bad in [
766 "",
767 "/etc/passwd",
768 "../secrets",
769 "a/../../b",
770 "a//b",
771 "a/./b",
772 "a/",
773 "a\\b",
774 "a\nb",
775 "nul\0",
776 ] {
777 assert!(bad.parse::<DataFilePath>().is_err(), "{bad:?}");
778 assert!(
779 serde_json::from_value::<DataFilePath>(serde_json::json!(bad))
780 .is_err(),
781 "{bad:?} via serde"
782 );
783 }
784 assert!("a".repeat(1025).parse::<DataFilePath>().is_err());
785 }
786
787 #[test]
788 fn a_commit_is_twenty_bytes_of_hex() {
789 assert!("0123456789abcdef".parse::<GitCommitId>().is_err());
790 assert!(
791 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
792 .parse::<GitCommitId>()
793 .is_err()
794 );
795 let id: GitCommitId =
796 "0123456789ABCDEF0123456789abcdef01234567".parse().unwrap();
797 assert_eq!(id.to_hex(), "0123456789abcdef0123456789abcdef01234567");
798 }
799}