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
82/// What [`DataSet::resolve_ranged`] finds: the file and its range (or the
83/// range's error), or why the file part names no single file
84pub type RangedLookup<'a> = Result<
85    (
86        DataSetTarget<'a>,
87        Result<super::LineRange, super::LineRangeError>,
88    ),
89    DataSetLookupError,
90>;
91
92impl DataSet {
93    /// What a writer refuses to sign: another version, a blank text field,
94    /// no files, paths out of order or repeated, an index that is not
95    /// listed, or a note that is blank or more than one line
96    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    /// The listed file at `path`, if it is listed
134    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            // Unsorted (an invalid manifest): still never a wrong answer.
140            .or_else(|| self.files.iter().find(|f| &f.path == path))
141    }
142
143    /// Every file's size, added up
144    pub fn total_bytes(&self) -> u64 {
145        self.files.iter().map(|f| f.bytes).sum()
146    }
147
148    /// The files under `prefix` (a directory ending in `/`): how many, and
149    /// their bytes added up
150    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    /// The directories at the repository root that hold files, in order
158    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    /// What `attachment=<asked>` names: a listed file, or a directory
171    /// (ending in `/`). Paths are relative to the repository root; one
172    /// relative to a directory at the root (`README.md` for
173    /// `votes/README.md`, the way a set's own files cite each other)
174    /// resolves when exactly one directory holds it, and the target says
175    /// what was asked (`asked` is set only then). Surrounding whitespace
176    /// and a leading `./` or `/` are ignored. (0.91.4)
177    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        // One match, or the error for none or several
199        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    /// `asked` read as `<file>:<range>`, for an `asked` that names nothing
266    /// as it stands: when the part before its last `:` names a listed
267    /// file, that file and the part after read as a
268    /// [`LineRange`](super::LineRange), with the errors `lines=` gives.
269    /// `None` when it does not, or when what follows the `:` does not look
270    /// like a range (`notes.md:v2`), so the caller's own miss stands. A
271    /// file part that names several files is that error (0.105.0)
272    ///
273    /// A citation's range is parsed strictly (`<from>-<to>`), so a path
274    /// such as `log:12` stays a path; here the listing has settled that no
275    /// such file exists, and `rubric.md:8-3` or `rubric.md:8` is a range
276    /// of `rubric.md`, refused or read as written.
277    pub fn resolve_ranged(&self, asked: &str) -> Option<RangedLookup<'_>> {
278        let (path, range) = asked.trim().rsplit_once(':')?;
279        // Digits, with what a range is written with (`L8-L30`, `8..30`,
280        // `lines 8-30`, `8 to 30`), and nothing else: `v2` is a path's
281        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        // Found as asked, or a miss of its own to report
307        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/// What a data set's `attachment=` named (0.91.4)
323#[derive(Debug, Clone, PartialEq, Eq)]
324pub enum DataSetTarget<'a> {
325    /// A listed file
326    File {
327        file: &'a DataFile,
328        /// What was asked, when it is not the file's path
329        asked: Option<String>,
330    },
331    /// The files under a directory: `prefix` ends in `/`
332    Directory {
333        prefix: String,
334        /// What was asked, when it is not `prefix`
335        asked: Option<String>,
336    },
337}
338
339/// `attachment=` names no file or directory of a data set: the message says
340/// what it was and what to ask for instead, in full (0.91.4)
341#[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/// Why a [`DataSetLookupError`]
349#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
350pub enum DataSetLookupFailure {
351    /// A directory, asked for without its trailing `/`
352    #[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    /// A path relative to a top directory that more than one holds
363    #[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    /// The listing for `bytes` read at `path`, with no note
380    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    /// `Ok` when `bytes` are exactly the bytes this listing signs for
390    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/// The bytes read for a [`DataFile`] are not the ones its manifest signs
406/// for. Never serve them.
407#[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/// A string that is not a [`DataRepoName`]
421#[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/// The name of a data repository, as a [`DataSet`] names it: lowercase
429/// letters, digits and `-`, starting with a letter or digit, at most 63
430/// bytes (0.103). Nothing in it can name a path outside a directory of
431/// repositories (no `.`, `/` or `\`), so a server may join it to one before
432/// touching the filesystem.
433#[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    /// The longest name accepted, in bytes
441    pub const MAX_LEN: usize = 63;
442
443    pub fn as_str(&self) -> &str {
444        &self.0
445    }
446
447    /// The repository's directory name: `<name>.git`
448    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/// A path that is not a [`DataFilePath`]
516#[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/// A file's path in a data repository: relative to its root and
524/// `/`-separated, with nothing in it that could climb out of the tree
525#[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    /// The longest path accepted, in bytes
533    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// Inline, like every id newtype: a derived schema would be a `$ref`.
581#[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            // Sorted: "R" (0x52) before "b" (0x62).
625            files: vec![
626                readme,
627                DataFile::of(path("votes/ballots.csv"), b"seat,vote\n"),
628            ],
629        }
630    }
631
632    /// A citation's `:<range>` that the strict parse left in the path is
633    /// read as a range once the listing settles that no such file exists
634    /// (beta findings, 2026-10-19)
635    #[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        // Nothing to split, a miss either side, a directory, or a listed
662        // file: `None`, so the caller's own answer stands
663        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        // A file under two top directories: that error, not a miss
681        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            // sha256("seat,vote\n")
705            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        // Same length, one byte changed: the hash is what catches it.
755        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    /// What `attachment=` resolves to: exact paths, directories, a path
763    /// relative to a top directory, and errors that say what to ask for
764    /// (beta round, 2026-10-15; 0.91.4)
765    #[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        // Relative to the one top directory, as the set's files cite it
778        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        // Normalised, not resolved: nothing to say
787        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        // A directory without its `/`: says so, with what is in it
812        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        // Under two top directories: ambiguous, never a guess
837        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    /// A repository name cannot leave the directory of repositories
860    /// (0.103)
861    #[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}