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