Skip to main content

agora_agentkit/govlog/
data_set.rs

1//! The `data` of a `data_set` entry (`DATA-YYYY-NNNN`): the signed
2//! manifest of a data set whose bytes live in a git repository.
3//!
4//! The entry pins one [commit](GitCommitId) and lists every file with its
5//! size and [SHA-256](Sha256Hex), so the signature covers the bytes and not
6//! just a name for them. A server serves only the paths listed here and only
7//! after [checking](DataFile::check) each one; a correction is a revision
8//! naming a new commit, never a moving ref.
9//!
10//! Redactable like a [`StewardRecord`](super::StewardRecord): the prose
11//! fields are plain strings a redaction marker can replace.
12
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16use super::{GitCommitId, Sha256Hex};
17
18/// The only `agora_data_set` version there is
19pub const DATA_SET_VERSION: u32 = 1;
20
21/// The largest file one `get_content` read shows whole: what it holds at
22/// its largest `comment_budget`. A larger listed file is read by
23/// [`LineRange`] only (0.103)
24pub const MAX_WHOLE_READ_BYTES: u64 =
25    crate::requests::GetContentInput::MAX_COMMENT_BUDGET as u64;
26
27/// The largest file a data set may list, 16 MiB (0.103; until then the
28/// same as [`MAX_WHOLE_READ_BYTES`]). A server refuses to read a larger
29/// blob at all.
30///
31/// Why 16 MiB: a ranged read still inflates the whole blob and checks its
32/// SHA-256 before it serves any line (a slice of an unchecked blob is never
33/// served), so this bounds the memory and hashing one read can cost. It is
34/// also the object cap gitoxide applies under reduced trust, the limit the
35/// data repository's readers were built and tested against (agora
36/// `memory/project_data_sets.md`). Larger data belongs split across files.
37pub const MAX_DATA_FILE_BYTES: u64 = 16 * 1024 * 1024;
38
39/// The `data` of a `data_set` entry. See the [module docs](self).
40///
41/// Unknown keys are tolerated, as on a record: a stored manifest also
42/// carries the writer's `_blind`.
43#[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    /// Always [`DATA_SET_VERSION`]
48    pub agora_data_set: u32,
49    pub title: String,
50    /// What the data is, where it came from and how to read it (markdown)
51    pub description: String,
52    /// Who compiled it, in their own words
53    pub compiled_by: String,
54    /// The data repository's name, as it is cloned (`agora-data`). Each
55    /// data set may have its own repository (0.103): a server reads it at
56    /// `<its data repositories>/<repo>.git`
57    pub repo: DataRepoName,
58    /// The commit every file is read at
59    pub commit: GitCommitId,
60    /// The file a reader should open first; one of `files`
61    pub index: DataFilePath,
62    /// Every file a server may serve, sorted by path
63    pub files: Vec<DataFile>,
64}
65
66/// One file of a [`DataSet`]
67#[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    /// Relative to the repository root
72    pub path: DataFilePath,
73    /// Size in bytes
74    pub bytes: u64,
75    /// SHA-256 of the file's bytes (not git's object hash)
76    pub sha256: Sha256Hex,
77    /// One line on what the file holds
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub note: Option<String>,
80}
81
82impl DataSet {
83    /// What a writer refuses to sign: another version, a blank text field,
84    /// no files, paths out of order or repeated, an index that is not
85    /// listed, or a note that is blank or more than one line
86    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    /// The listed file at `path`, if it is listed
124    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            // Unsorted (an invalid manifest): still never a wrong answer.
130            .or_else(|| self.files.iter().find(|f| &f.path == path))
131    }
132
133    /// Every file's size, added up
134    pub fn total_bytes(&self) -> u64 {
135        self.files.iter().map(|f| f.bytes).sum()
136    }
137
138    /// The files under `prefix` (a directory ending in `/`): how many, and
139    /// their bytes added up
140    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    /// The directories at the repository root that hold files, in order
148    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    /// What `attachment=<asked>` names: a listed file, or a directory
161    /// (ending in `/`). Paths are relative to the repository root; one
162    /// relative to a directory at the root (`README.md` for
163    /// `votes/README.md`, the way a set's own files cite each other)
164    /// resolves when exactly one directory holds it, and the target says
165    /// what was asked (`asked` is set only then). Surrounding whitespace
166    /// and a leading `./` or `/` are ignored. (0.91.4)
167    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        // One match, or the error for none or several
189        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/// What a data set's `attachment=` named (0.91.4)
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum DataSetTarget<'a> {
257    /// A listed file
258    File {
259        file: &'a DataFile,
260        /// What was asked, when it is not the file's path
261        asked: Option<String>,
262    },
263    /// The files under a directory: `prefix` ends in `/`
264    Directory {
265        prefix: String,
266        /// What was asked, when it is not `prefix`
267        asked: Option<String>,
268    },
269}
270
271/// `attachment=` names no file or directory of a data set: the message says
272/// what it was and what to ask for instead, in full (0.91.4)
273#[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/// Why a [`DataSetLookupError`]
281#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
282pub enum DataSetLookupFailure {
283    /// A directory, asked for without its trailing `/`
284    #[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    /// A path relative to a top directory that more than one holds
295    #[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    /// The listing for `bytes` read at `path`, with no note
312    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    /// `Ok` when `bytes` are exactly the bytes this listing signs for
322    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/// The bytes read for a [`DataFile`] are not the ones its manifest signs
338/// for. Never serve them.
339#[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/// A string that is not a [`DataRepoName`]
353#[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/// The name of a data repository, as a [`DataSet`] names it: lowercase
361/// letters, digits and `-`, starting with a letter or digit, at most 63
362/// bytes (0.103). Nothing in it can name a path outside a directory of
363/// repositories (no `.`, `/` or `\`), so a server may join it to one before
364/// touching the filesystem.
365#[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    /// The longest name accepted, in bytes
373    pub const MAX_LEN: usize = 63;
374
375    pub fn as_str(&self) -> &str {
376        &self.0
377    }
378
379    /// The repository's directory name: `<name>.git`
380    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/// A path that is not a [`DataFilePath`]
448#[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/// A file's path in a data repository: relative to its root and
456/// `/`-separated, with nothing in it that could climb out of the tree
457#[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    /// The longest path accepted, in bytes
465    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// Inline, like every id newtype: a derived schema would be a `$ref`.
513#[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            // Sorted: "R" (0x52) before "b" (0x62).
557            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            // sha256("seat,vote\n")
575            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        // Same length, one byte changed: the hash is what catches it.
625        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    /// What `attachment=` resolves to: exact paths, directories, a path
633    /// relative to a top directory, and errors that say what to ask for
634    /// (beta round, 2026-10-15; 0.91.4)
635    #[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        // Relative to the one top directory, as the set's files cite it
648        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        // Normalised, not resolved: nothing to say
657        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        // A directory without its `/`: says so, with what is in it
682        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        // Under two top directories: ambiguous, never a guess
707        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    /// A repository name cannot leave the directory of repositories
730    /// (0.103)
731    #[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}