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
82pub type RangedLookup<'a> = Result<
85 (
86 DataSetTarget<'a>,
87 Result<super::LineRange, super::LineRangeError>,
88 ),
89 DataSetLookupError,
90>;
91
92impl DataSet {
93 pub fn validate(&self) -> Result<(), &'static str> {
97 if self.agora_data_set != DATA_SET_VERSION {
98 return Err("agora_data_set is not the version this writer writes");
99 }
100 let blank = |s: &str| s.trim().is_empty();
101 if blank(&self.title)
102 || blank(&self.description)
103 || blank(&self.compiled_by)
104 {
105 return Err(
106 "title, description and compiled_by must all say something",
107 );
108 }
109 if self.files.is_empty() {
110 return Err("a data set lists at least one file");
111 }
112 if self.files.windows(2).any(|w| w[0].path >= w[1].path) {
113 return Err("files must be sorted by path, each listed once");
114 }
115 if self.files.iter().any(|f| f.bytes > MAX_DATA_FILE_BYTES) {
116 return Err(
117 "a file is larger than MAX_DATA_FILE_BYTES (16 MiB); split it",
118 );
119 }
120 if self.file(&self.index).is_none() {
121 return Err("the index must be one of the listed files");
122 }
123 if self.files.iter().any(|f| {
124 f.note
125 .as_deref()
126 .is_some_and(|n| blank(n) || n.contains(['\n', '\r']))
127 }) {
128 return Err("a file's note is one line, or left out");
129 }
130 Ok(())
131 }
132
133 pub fn file(&self, path: &DataFilePath) -> Option<&DataFile> {
135 self.files
136 .binary_search_by(|f| f.path.cmp(path))
137 .ok()
138 .map(|i| &self.files[i])
139 .or_else(|| self.files.iter().find(|f| &f.path == path))
141 }
142
143 pub fn total_bytes(&self) -> u64 {
145 self.files.iter().map(|f| f.bytes).sum()
146 }
147
148 pub fn under(&self, prefix: &str) -> (u64, u64) {
151 self.files
152 .iter()
153 .filter(|f| f.path.as_str().starts_with(prefix))
154 .fold((0, 0), |(n, b), f| (n + 1, b + f.bytes))
155 }
156
157 fn top_directories(&self) -> Vec<&str> {
159 let mut dirs: Vec<&str> = Vec::new();
160 for f in &self.files {
161 if let Some((dir, _)) = f.path.as_str().split_once('/')
162 && !dirs.contains(&dir)
163 {
164 dirs.push(dir);
165 }
166 }
167 dirs
168 }
169
170 pub fn resolve(
178 &self,
179 asked: &str,
180 ) -> Result<DataSetTarget<'_>, DataSetLookupError> {
181 let trimmed = asked.trim();
182 let path = trimmed.strip_prefix("./").unwrap_or(trimmed);
183 let path = path.trim_start_matches('/');
184 let lookup_error = |kind| DataSetLookupError {
185 asked: asked.to_string(),
186 kind,
187 };
188 if path.is_empty() {
189 return Err(lookup_error(DataSetLookupFailure::NotFound));
190 }
191 let tops = self.top_directories();
192 let file_at =
193 |p: &str| p.parse().ok().and_then(|p: DataFilePath| self.file(&p));
194 let directory = |prefix: String| {
195 let (files, bytes) = self.under(&prefix);
196 (files > 0).then_some((prefix, files, bytes))
197 };
198 let one = |mut found: Vec<String>| match found.len() {
200 1 => Ok(found.pop()),
201 0 => Ok(None),
202 _ => Err(lookup_error(DataSetLookupFailure::Ambiguous(found))),
203 };
204 if path.ends_with('/') {
205 if directory(path.to_string()).is_some() {
206 return Ok(DataSetTarget::Directory {
207 prefix: path.to_string(),
208 asked: None,
209 });
210 }
211 let found = tops
212 .iter()
213 .filter_map(|top| directory(format!("{top}/{path}")))
214 .map(|(prefix, ..)| prefix)
215 .collect();
216 return match one(found)? {
217 Some(prefix) => Ok(DataSetTarget::Directory {
218 asked: Some(asked.to_string()),
219 prefix,
220 }),
221 None => Err(lookup_error(DataSetLookupFailure::NotFound)),
222 };
223 }
224 if let Some(file) = file_at(path) {
225 return Ok(DataSetTarget::File { file, asked: None });
226 }
227 let not_a_file = |(prefix, files, bytes)| {
228 lookup_error(DataSetLookupFailure::Directory {
229 prefix,
230 files,
231 bytes,
232 })
233 };
234 if let Some(dir) = directory(format!("{path}/")) {
235 return Err(not_a_file(dir));
236 }
237 let found = tops
238 .iter()
239 .map(|top| format!("{top}/{path}"))
240 .filter(|p| file_at(p).is_some())
241 .collect();
242 if let Some(full) = one(found)?
243 && let Some(file) = file_at(&full)
244 {
245 return Ok(DataSetTarget::File {
246 file,
247 asked: Some(asked.to_string()),
248 });
249 }
250 let dirs: Vec<_> = tops
251 .iter()
252 .filter_map(|top| directory(format!("{top}/{path}/")))
253 .collect();
254 match dirs.len() {
255 0 => Err(lookup_error(DataSetLookupFailure::NotFound)),
256 1 => Err(not_a_file(dirs.into_iter().next().expect("one"))),
257 _ => Err(lookup_error(DataSetLookupFailure::Ambiguous(
258 dirs.into_iter().map(|(prefix, ..)| prefix).collect(),
259 ))),
260 }
261 }
262}
263
264impl DataSet {
265 pub fn resolve_ranged(&self, asked: &str) -> Option<RangedLookup<'_>> {
278 let (path, range) = asked.trim().rsplit_once(':')?;
279 let r = range.trim().to_ascii_lowercase().replace(" to ", "-");
282 let r = r
283 .strip_prefix("lines")
284 .or_else(|| r.strip_prefix("line"))
285 .unwrap_or(&r);
286 let rangeish = r.chars().any(|c| c.is_ascii_digit())
287 && r.chars().all(|c| {
288 c.is_ascii_digit()
289 || matches!(
290 c,
291 'l' | '-'
292 | '.'
293 | '#'
294 | '='
295 | ' '
296 | '\u{2013}'
297 | '\u{2014}'
298 )
299 });
300 if !rangeish {
301 return None;
302 }
303 let ambiguous = |e: &DataSetLookupError| {
304 matches!(e.kind, DataSetLookupFailure::Ambiguous(_))
305 };
306 match self.resolve(asked) {
308 Ok(_) => return None,
309 Err(e) if ambiguous(&e) => return None,
310 Err(_) => {}
311 }
312 match self.resolve(path) {
313 Ok(target @ DataSetTarget::File { .. }) => {
314 Some(Ok((target, range.parse())))
315 }
316 Err(e) if ambiguous(&e) => Some(Err(e)),
317 _ => None,
318 }
319 }
320}
321
322#[derive(Debug, Clone, PartialEq, Eq)]
324pub enum DataSetTarget<'a> {
325 File {
327 file: &'a DataFile,
328 asked: Option<String>,
330 },
331 Directory {
333 prefix: String,
334 asked: Option<String>,
336 },
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
342#[error("attachment={asked:?}: {kind}")]
343pub struct DataSetLookupError {
344 pub asked: String,
345 pub kind: DataSetLookupFailure,
346}
347
348#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
350pub enum DataSetLookupFailure {
351 #[error(
353 "{prefix} is a directory of this data set ({files} files, {bytes} \
354 bytes), not a file. List it with attachment=\"{prefix}\" (a directory \
355 ends in `/`), or read one of its files by its path."
356 )]
357 Directory {
358 prefix: String,
359 files: u64,
360 bytes: u64,
361 },
362 #[error(
364 "that path is under more than one of this data set's top directories: \
365 {}. Ask for one of them by its full path.",
366 .0.join(", ")
367 )]
368 Ambiguous(Vec<String>),
369 #[error(
370 "no file or directory of this data set is at that path. Paths are \
371 relative to the data repository's root (a path relative to a \
372 directory at the root also works when only one directory holds it); \
373 a directory ends in `/`."
374 )]
375 NotFound,
376}
377
378impl DataFile {
379 pub fn of(path: DataFilePath, bytes: &[u8]) -> Self {
381 Self {
382 path,
383 bytes: bytes.len() as u64,
384 sha256: Sha256Hex::from(<[u8; 32]>::from(Sha256::digest(bytes))),
385 note: None,
386 }
387 }
388
389 pub fn check(&self, bytes: &[u8]) -> Result<(), DataFileMismatch> {
391 let got = Self::of(self.path.clone(), bytes);
392 if got.bytes == self.bytes && got.sha256 == self.sha256 {
393 return Ok(());
394 }
395 Err(DataFileMismatch {
396 path: self.path.clone(),
397 expected_bytes: self.bytes,
398 expected_sha256: self.sha256,
399 got_bytes: got.bytes,
400 got_sha256: got.sha256,
401 })
402 }
403}
404
405#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
408#[error(
409 "{path}: the manifest signs for {expected_bytes} bytes with SHA-256 \
410 {expected_sha256}, but {got_bytes} bytes with SHA-256 {got_sha256} were read"
411)]
412pub struct DataFileMismatch {
413 pub path: DataFilePath,
414 pub expected_bytes: u64,
415 pub expected_sha256: Sha256Hex,
416 pub got_bytes: u64,
417 pub got_sha256: Sha256Hex,
418}
419
420#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
422#[error(
423 "not a data repository name (1 to 63 lowercase letters, digits and `-`, \
424 starting with a letter or digit): {0:?}"
425)]
426pub struct DataRepoNameError(pub String);
427
428#[derive(
434 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
435)]
436#[serde(try_from = "String", into = "String")]
437pub struct DataRepoName(String);
438
439impl DataRepoName {
440 pub const MAX_LEN: usize = 63;
442
443 pub fn as_str(&self) -> &str {
444 &self.0
445 }
446
447 pub fn dir_name(&self) -> String {
449 format!("{}.git", self.0)
450 }
451}
452
453impl std::str::FromStr for DataRepoName {
454 type Err = DataRepoNameError;
455
456 fn from_str(s: &str) -> Result<Self, Self::Err> {
457 let ok = (1..=Self::MAX_LEN).contains(&s.len())
458 && s.bytes().all(|b| {
459 b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'
460 })
461 && !s.starts_with('-');
462 ok.then(|| Self(s.to_string()))
463 .ok_or_else(|| DataRepoNameError(s.to_string()))
464 }
465}
466
467impl TryFrom<String> for DataRepoName {
468 type Error = DataRepoNameError;
469
470 fn try_from(s: String) -> Result<Self, Self::Error> {
471 s.parse()
472 }
473}
474
475impl From<DataRepoName> for String {
476 fn from(name: DataRepoName) -> Self {
477 name.0
478 }
479}
480
481impl std::fmt::Display for DataRepoName {
482 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
483 f.write_str(&self.0)
484 }
485}
486
487impl AsRef<str> for DataRepoName {
488 fn as_ref(&self) -> &str {
489 &self.0
490 }
491}
492
493#[cfg(feature = "schemars")]
494impl schemars::JsonSchema for DataRepoName {
495 fn inline_schema() -> bool {
496 true
497 }
498
499 fn schema_name() -> std::borrow::Cow<'static, str> {
500 std::borrow::Cow::Borrowed("DataRepoName")
501 }
502
503 fn schema_id() -> std::borrow::Cow<'static, str> {
504 std::borrow::Cow::Borrowed(concat!(module_path!(), "::DataRepoName"))
505 }
506
507 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
508 schemars::json_schema!({
509 "type": "string",
510 "description": "A data repository's name, e.g. \"agora-data\"",
511 })
512 }
513}
514
515#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
517#[error(
518 "not a data file path (relative, `/`-separated, no empty, `.` or `..` \
519 parts, no backslashes or control characters): {0:?}"
520)]
521pub struct DataFilePathError(pub String);
522
523#[derive(
526 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
527)]
528#[serde(try_from = "String", into = "String")]
529pub struct DataFilePath(String);
530
531impl DataFilePath {
532 pub const MAX_LEN: usize = 1024;
534
535 pub fn as_str(&self) -> &str {
536 &self.0
537 }
538}
539
540impl std::str::FromStr for DataFilePath {
541 type Err = DataFilePathError;
542
543 fn from_str(s: &str) -> Result<Self, Self::Err> {
544 let ok = !s.is_empty()
545 && s.len() <= Self::MAX_LEN
546 && !s.chars().any(|c| c == '\\' || c.is_control())
547 && s.split('/')
548 .all(|part| !part.is_empty() && part != "." && part != "..");
549 ok.then(|| Self(s.to_string()))
550 .ok_or_else(|| DataFilePathError(s.to_string()))
551 }
552}
553
554impl TryFrom<String> for DataFilePath {
555 type Error = DataFilePathError;
556
557 fn try_from(s: String) -> Result<Self, Self::Error> {
558 s.parse()
559 }
560}
561
562impl From<DataFilePath> for String {
563 fn from(path: DataFilePath) -> Self {
564 path.0
565 }
566}
567
568impl std::fmt::Display for DataFilePath {
569 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
570 f.write_str(&self.0)
571 }
572}
573
574impl AsRef<str> for DataFilePath {
575 fn as_ref(&self) -> &str {
576 &self.0
577 }
578}
579
580#[cfg(feature = "schemars")]
582impl schemars::JsonSchema for DataFilePath {
583 fn inline_schema() -> bool {
584 true
585 }
586
587 fn schema_name() -> std::borrow::Cow<'static, str> {
588 std::borrow::Cow::Borrowed("DataFilePath")
589 }
590
591 fn schema_id() -> std::borrow::Cow<'static, str> {
592 std::borrow::Cow::Borrowed(concat!(module_path!(), "::DataFilePath"))
593 }
594
595 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
596 schemars::json_schema!({
597 "type": "string",
598 "description": "A file's path in the data repository, relative \
599 to its root, e.g. \"votes/index.md\"",
600 })
601 }
602}
603
604#[cfg(test)]
605pub(super) mod tests {
606 use super::super::{Blind, blind_data, non_integer_number};
607 use super::*;
608
609 fn path(s: &str) -> DataFilePath {
610 s.parse().unwrap()
611 }
612
613 pub(crate) fn manifest() -> DataSet {
614 let mut readme = DataFile::of(path("votes/README.md"), b"# Votes\n");
615 readme.note = Some("Read me first".into());
616 DataSet {
617 agora_data_set: DATA_SET_VERSION,
618 title: "Ratification votes".into(),
619 description: "Every ballot cast on GOV-2026-0001.".into(),
620 compiled_by: "claude-opus, from the governance log".into(),
621 repo: "agora-data".parse().unwrap(),
622 commit: "0123456789abcdef0123456789abcdef01234567".parse().unwrap(),
623 index: path("votes/README.md"),
624 files: vec![
626 readme,
627 DataFile::of(path("votes/ballots.csv"), b"seat,vote\n"),
628 ],
629 }
630 }
631
632 #[test]
636 fn a_trailing_range_of_a_listed_file_is_read_as_one() {
637 let m = manifest();
638 let ranged = |asked: &str| {
639 m.resolve_ranged(asked).map(|found| {
640 let (target, lines) = found.unwrap();
641 let DataSetTarget::File { file, .. } = target else {
642 panic!("{asked}: not a file");
643 };
644 (file.path.to_string(), lines.map(|l| l.to_string()))
645 })
646 };
647 assert_eq!(
648 ranged("votes/ballots.csv:8"),
649 Some(("votes/ballots.csv".into(), Ok("8-8".into())))
650 );
651 assert_eq!(
652 ranged("ballots.csv:L2-L3"),
653 Some(("votes/ballots.csv".into(), Ok("2-3".into())))
654 );
655 let Some(Ok((_, Err(e)))) = m.resolve_ranged("votes/ballots.csv:8-3")
656 else {
657 panic!("8-3 is a refused range");
658 };
659 assert!(e.to_string().contains("ends before it starts"), "{e}");
660 assert!(e.to_string().contains(super::super::LineRange::FORM), "{e}");
661 for asked in [
664 "votes/ballots.csv",
665 "votes/missing.csv:8-3",
666 "votes:1-2",
667 "votes/ballots.csv:",
668 "votes/ballots.csv:v2",
669 "votes/ballots.csv:old",
670 ] {
671 assert_eq!(ranged(asked), None, "{asked}");
672 }
673 let mut colon = manifest();
674 colon
675 .files
676 .push(DataFile::of(path("votes/x.md:1-2"), b"x\n"));
677 colon.files.push(DataFile::of(path("votes/x.md"), b"x\n"));
678 colon.files.sort_by(|a, b| a.path.cmp(&b.path));
679 assert!(colon.resolve_ranged("votes/x.md:1-2").is_none());
680 let mut two = manifest();
682 two.files
683 .push(DataFile::of(path("other/ballots.csv"), b"x\n"));
684 two.files.sort_by(|a, b| a.path.cmp(&b.path));
685 assert!(matches!(
686 two.resolve_ranged("ballots.csv:8"),
687 Some(Err(DataSetLookupError {
688 kind: DataSetLookupFailure::Ambiguous(_),
689 ..
690 }))
691 ));
692 }
693
694 #[test]
695 fn a_manifest_validates_and_round_trips() {
696 let m = manifest();
697 assert_eq!(m.validate(), Ok(()));
698 let value = serde_json::to_value(&m).unwrap();
699 assert_eq!(value["agora_data_set"], 1);
700 assert_eq!(value["commit"], "0123456789abcdef0123456789abcdef01234567");
701 assert_eq!(value["files"][0]["path"], "votes/README.md");
702 assert_eq!(
703 value["files"][1]["sha256"],
704 hex::encode(Sha256::digest(b"seat,vote\n"))
706 );
707 assert!(value["files"][1].get("note").is_none());
708 assert_eq!(non_integer_number(&value), None);
709 let back: DataSet = serde_json::from_value(value).unwrap();
710 assert_eq!(back, m);
711 assert_eq!(m.total_bytes(), 8 + 10);
712 }
713
714 #[test]
715 fn a_stored_manifest_still_reads() {
716 let stored = blind_data(
717 &serde_json::to_value(manifest()).unwrap(),
718 Blind::from([7; 32]),
719 )
720 .unwrap();
721 let read: DataSet = serde_json::from_value(stored).unwrap();
722 assert_eq!(read, manifest());
723 }
724
725 #[test]
726 fn what_a_writer_refuses() {
727 let refused = |f: &dyn Fn(&mut DataSet)| {
728 let mut m = manifest();
729 f(&mut m);
730 m.validate().unwrap_err()
731 };
732 refused(&|m| m.agora_data_set = 2);
733 refused(&|m| m.compiled_by = " ".into());
734 refused(&|m| m.files.clear());
735 refused(&|m| m.files.reverse());
736 refused(&|m| {
737 let again = m.files[0].clone();
738 m.files.insert(0, again);
739 });
740 refused(&|m| m.index = path("votes/missing.md"));
741 refused(&|m| m.files[0].note = Some("two\nlines".into()));
742 refused(&|m| m.files[0].note = Some("".into()));
743 refused(&|m| m.files[1].bytes = MAX_DATA_FILE_BYTES + 1);
744 }
745
746 #[test]
747 fn a_tampered_file_is_refused() {
748 let m = manifest();
749 let ballots = m.file(&path("votes/ballots.csv")).unwrap();
750 assert_eq!(ballots.check(b"seat,vote\n"), Ok(()));
751 let err = ballots.check(b"seat,vote\nlawyer,yes\n").unwrap_err();
752 assert_eq!(err.expected_bytes, 10);
753 assert_eq!(err.got_bytes, 21);
754 let err = ballots.check(b"seat,VOTE\n").unwrap_err();
756 assert_eq!(err.got_bytes, err.expected_bytes);
757 assert_ne!(err.got_sha256, err.expected_sha256);
758 assert!(err.to_string().contains("votes/ballots.csv"), "{err}");
759 assert!(m.file(&path("votes/other.csv")).is_none());
760 }
761
762 #[test]
766 fn attachments_resolve_or_say_why_not() {
767 let mut m = manifest();
768 m.files.push(DataFile::of(path("votes/raw/r1.json"), b"{}"));
769 m.files.sort_by(|a, b| a.path.cmp(&b.path));
770 let file = |asked: &str| match m.resolve(asked) {
771 Ok(DataSetTarget::File { file, asked }) => {
772 (file.path.to_string(), asked)
773 }
774 other => panic!("{asked}: {other:?}"),
775 };
776 assert_eq!(file("votes/README.md"), ("votes/README.md".into(), None));
777 assert_eq!(
779 file("README.md"),
780 ("votes/README.md".into(), Some("README.md".into()))
781 );
782 assert_eq!(
783 file("raw/r1.json"),
784 ("votes/raw/r1.json".into(), Some("raw/r1.json".into()))
785 );
786 for exact in [
788 "./votes/ballots.csv",
789 "/votes/ballots.csv",
790 " votes/ballots.csv\n",
791 ] {
792 assert_eq!(
793 file(exact),
794 ("votes/ballots.csv".into(), None),
795 "{exact:?}"
796 );
797 }
798 for empty in ["", "/", "./", " "] {
799 assert_eq!(
800 m.resolve(empty).unwrap_err().kind,
801 DataSetLookupFailure::NotFound,
802 "{empty:?}"
803 );
804 }
805 let dir = |asked: &str| match m.resolve(asked) {
806 Ok(DataSetTarget::Directory { prefix, asked }) => (prefix, asked),
807 other => panic!("{asked}: {other:?}"),
808 };
809 assert_eq!(dir("votes/"), ("votes/".into(), None));
810 assert_eq!(dir("raw/"), ("votes/raw/".into(), Some("raw/".into())));
811 let err = m.resolve("votes/raw").unwrap_err();
813 assert_eq!(
814 err.kind,
815 DataSetLookupFailure::Directory {
816 prefix: "votes/raw/".into(),
817 files: 1,
818 bytes: 2
819 }
820 );
821 let text = err.to_string();
822 assert!(text.contains("votes/raw/ is a directory"), "{text}");
823 assert!(text.contains("attachment=\"votes/raw/\""), "{text}");
824 assert!(matches!(
825 m.resolve("raw").unwrap_err().kind,
826 DataSetLookupFailure::Directory { .. }
827 ));
828 assert_eq!(
829 m.resolve("votes/missing.csv").unwrap_err().kind,
830 DataSetLookupFailure::NotFound
831 );
832 assert_eq!(
833 m.resolve("../x").unwrap_err().kind,
834 DataSetLookupFailure::NotFound
835 );
836 m.files.push(DataFile::of(path("tallies/README.md"), b"t"));
838 m.files.sort_by(|a, b| a.path.cmp(&b.path));
839 let err = m.resolve("README.md").unwrap_err();
840 assert_eq!(
841 err.kind,
842 DataSetLookupFailure::Ambiguous(vec![
843 "tallies/README.md".into(),
844 "votes/README.md".into()
845 ])
846 );
847 m.files
848 .push(DataFile::of(path("tallies/raw/t.json"), b"{}"));
849 m.files.sort_by(|a, b| a.path.cmp(&b.path));
850 assert_eq!(
851 m.resolve("raw/").unwrap_err().kind,
852 DataSetLookupFailure::Ambiguous(vec![
853 "tallies/raw/".into(),
854 "votes/raw/".into()
855 ])
856 );
857 }
858
859 #[test]
862 fn repo_names_are_one_plain_word() {
863 for good in ["agora-data", "a", "witness-2026-10-18", &"a".repeat(63)] {
864 assert_eq!(good.parse::<DataRepoName>().unwrap().as_str(), good);
865 }
866 assert_eq!(
867 "agora-data".parse::<DataRepoName>().unwrap().dir_name(),
868 "agora-data.git"
869 );
870 for bad in [
871 "",
872 "-a",
873 "Agora",
874 "a.git",
875 "..",
876 "a/b",
877 "a\\b",
878 "a b",
879 "a_b",
880 "/etc",
881 &"a".repeat(64),
882 ] {
883 assert!(bad.parse::<DataRepoName>().is_err(), "{bad:?}");
884 }
885 let mut m = serde_json::to_value(manifest()).unwrap();
886 m["repo"] = "../secrets".into();
887 assert!(serde_json::from_value::<DataSet>(m).is_err());
888 }
889
890 #[test]
891 fn paths_cannot_climb_out() {
892 for good in ["index.md", "a/b/c.csv", "with space/ΓΌ.txt", ".hidden"] {
893 assert_eq!(path(good).as_str(), good);
894 }
895 for bad in [
896 "",
897 "/etc/passwd",
898 "../secrets",
899 "a/../../b",
900 "a//b",
901 "a/./b",
902 "a/",
903 "a\\b",
904 "a\nb",
905 "nul\0",
906 ] {
907 assert!(bad.parse::<DataFilePath>().is_err(), "{bad:?}");
908 assert!(
909 serde_json::from_value::<DataFilePath>(serde_json::json!(bad))
910 .is_err(),
911 "{bad:?} via serde"
912 );
913 }
914 assert!("a".repeat(1025).parse::<DataFilePath>().is_err());
915 }
916
917 #[test]
918 fn a_commit_is_twenty_bytes_of_hex() {
919 assert!("0123456789abcdef".parse::<GitCommitId>().is_err());
920 assert!(
921 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
922 .parse::<GitCommitId>()
923 .is_err()
924 );
925 let id: GitCommitId =
926 "0123456789ABCDEF0123456789abcdef01234567".parse().unwrap();
927 assert_eq!(id.to_hex(), "0123456789abcdef0123456789abcdef01234567");
928 }
929}