Skip to main content

tak_cli/
artifact.rs

1//! Portable handoff between a read-only measurement job and a write-capable
2//! publisher.
3//!
4//! The publisher must not trust the artifact to choose its target commit. CI
5//! supplies an independently trusted revision and publication proceeds only
6//! when the artifact names that exact commit. Records are deserialised and then
7//! written through [`crate::notes`], so malformed input cannot bypass Tak's
8//! canonical line format or its non-forced, merge-and-retry push path.
9
10use anyhow::{Context, Result, bail};
11use serde::{Deserialize, Serialize};
12use std::fs;
13use std::io::Write;
14use std::path::{Path, PathBuf};
15
16use crate::notes;
17use crate::record::{Record, SCHEMA_VERSION};
18
19const ARTIFACT_VERSION: u32 = 1;
20const MAX_ARTIFACT_BYTES: u64 = 16 * 1024 * 1024;
21const MAX_RECORDS: usize = 10_000;
22
23#[derive(Debug, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25struct MeasurementArtifact {
26    v: u32,
27    commit: String,
28    records: Vec<Record>,
29}
30
31fn artifact_parent(path: &Path) -> PathBuf {
32    path.parent()
33        .filter(|p| !p.as_os_str().is_empty())
34        .unwrap_or_else(|| Path::new("."))
35        .to_path_buf()
36}
37
38/// Account for the trailing newline written after the JSON payload so every
39/// successfully exported file also passes the publisher's size check.
40fn ensure_export_size(payload_bytes: usize) -> Result<()> {
41    let file_bytes = payload_bytes as u64 + 1;
42    if file_bytes > MAX_ARTIFACT_BYTES {
43        bail!("artifact is {file_bytes} bytes; the limit is {MAX_ARTIFACT_BYTES}");
44    }
45    Ok(())
46}
47
48/// Export every local Tak record attached to `rev` as one bounded, versioned
49/// file suitable for an artifact upload.
50pub fn export(path: &Path, rev: &str) -> Result<(String, usize)> {
51    let commit = notes::rev_parse(rev).with_context(|| format!("cannot resolve {rev}"))?;
52    let records = notes::read(None, &commit)?;
53    if records.is_empty() {
54        bail!("no measurements recorded for {}", &commit[..12]);
55    }
56    if records.len() > MAX_RECORDS {
57        bail!(
58            "refusing to export {} records; the artifact limit is {MAX_RECORDS}",
59            records.len()
60        );
61    }
62
63    let artifact = MeasurementArtifact {
64        v: ARTIFACT_VERSION,
65        commit: commit.clone(),
66        records,
67    };
68    let bytes = serde_json::to_vec(&artifact)?;
69    ensure_export_size(bytes.len())?;
70
71    let parent = artifact_parent(path);
72    fs::create_dir_all(&parent)
73        .with_context(|| format!("could not create {}", parent.display()))?;
74    let mut temp = tempfile::NamedTempFile::new_in(&parent)
75        .with_context(|| format!("could not create a temporary file in {}", parent.display()))?;
76    temp.write_all(&bytes)?;
77    temp.write_all(b"\n")?;
78    temp.as_file().sync_all()?;
79    temp.persist(path)
80        .map_err(|e| e.error)
81        .with_context(|| format!("could not write {}", path.display()))?;
82
83    Ok((commit, artifact.records.len()))
84}
85
86fn read(path: &Path) -> Result<MeasurementArtifact> {
87    let metadata = fs::metadata(path)
88        .with_context(|| format!("could not inspect artifact {}", path.display()))?;
89    if metadata.len() > MAX_ARTIFACT_BYTES {
90        bail!(
91            "artifact is {} bytes; the limit is {MAX_ARTIFACT_BYTES}",
92            metadata.len()
93        );
94    }
95    let bytes = fs::read(path).with_context(|| format!("could not read {}", path.display()))?;
96    let artifact: MeasurementArtifact = serde_json::from_slice(&bytes)
97        .with_context(|| format!("{} is not a valid Tak artifact", path.display()))?;
98
99    if artifact.v != ARTIFACT_VERSION {
100        bail!(
101            "unsupported artifact version {} (expected {ARTIFACT_VERSION})",
102            artifact.v
103        );
104    }
105    if artifact.records.is_empty() {
106        bail!("artifact contains no measurements");
107    }
108    if artifact.records.len() > MAX_RECORDS {
109        bail!(
110            "artifact contains {} records; the limit is {MAX_RECORDS}",
111            artifact.records.len()
112        );
113    }
114    for record in &artifact.records {
115        if record.v != SCHEMA_VERSION {
116            bail!(
117                "artifact contains record schema {} (expected {SCHEMA_VERSION})",
118                record.v
119            );
120        }
121        // Force every accepted record through the same canonical serializer the
122        // notes merge strategy relies on. This also rejects non-finite metrics.
123        record
124            .to_line()
125            .context("artifact contains an invalid record")?;
126        if record
127            .metrics
128            .get("instructions")
129            .is_some_and(|instructions| *instructions <= 0.0)
130        {
131            bail!("artifact contains a non-positive instruction count");
132        }
133    }
134    Ok(artifact)
135}
136
137/// Validate, import, and publish an artifact. `expect_rev` comes from the
138/// controlling workflow, never from the measurement job or its artifact.
139pub fn publish(path: &Path, expect_rev: &str, remote: &str) -> Result<(String, usize)> {
140    let artifact = read(path)?;
141    let expected = notes::rev_parse(expect_rev)
142        .with_context(|| format!("cannot resolve expected revision {expect_rev}"))?;
143    if artifact.commit != expected {
144        bail!(
145            "artifact targets {}, but expected {}",
146            artifact.commit,
147            expected
148        );
149    }
150
151    notes::fetch(remote)?;
152    notes::append(&expected, &artifact.records)?;
153    notes::push(remote)?;
154    Ok((expected, artifact.records.len()))
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use std::collections::BTreeMap;
161
162    fn record() -> Record {
163        Record {
164            v: SCHEMA_VERSION,
165            bench: "startup".into(),
166            tool: "self".into(),
167            version: None,
168            runner: "test-runner".into(),
169            ts: "2026-08-31T00:00:00Z".into(),
170            metrics: BTreeMap::from([("instructions".into(), 123.0)]),
171        }
172    }
173
174    #[test]
175    fn unknown_fields_are_rejected() {
176        let json = r#"{"v":1,"commit":"abc","records":[],"surprise":true}"#;
177        assert!(serde_json::from_str::<MeasurementArtifact>(json).is_err());
178    }
179
180    #[test]
181    fn records_roundtrip_canonically() {
182        let artifact = MeasurementArtifact {
183            v: ARTIFACT_VERSION,
184            commit: "0".repeat(40),
185            records: vec![record()],
186        };
187        let json = serde_json::to_string(&artifact).unwrap();
188        let back: MeasurementArtifact = serde_json::from_str(&json).unwrap();
189        assert_eq!(
190            back.records[0].to_line().unwrap(),
191            record().to_line().unwrap()
192        );
193    }
194
195    #[test]
196    fn export_limit_includes_the_trailing_newline() {
197        assert!(ensure_export_size(MAX_ARTIFACT_BYTES as usize - 1).is_ok());
198        assert!(ensure_export_size(MAX_ARTIFACT_BYTES as usize).is_err());
199    }
200
201    #[test]
202    fn non_positive_instruction_counts_are_rejected() {
203        for instructions in [-1.0, 0.0] {
204            let mut invalid = record();
205            invalid.metrics.insert("instructions".into(), instructions);
206            let artifact = MeasurementArtifact {
207                v: ARTIFACT_VERSION,
208                commit: "0".repeat(40),
209                records: vec![invalid],
210            };
211            let file = tempfile::NamedTempFile::new().unwrap();
212            serde_json::to_writer(file.as_file(), &artifact).unwrap();
213            assert!(read(file.path()).is_err());
214        }
215    }
216}