Skip to main content

super_types/
lib.rs

1//! Types for Super API requests, results, source uploads, and events.
2
3pub mod credential;
4pub mod usage;
5
6use std::collections::BTreeSet;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11use uuid::Uuid;
12
13pub const MAX_SOURCE_FILES: usize = 100_000;
14pub const MAX_SOURCE_FILE_BYTES: u64 = 10 * 1024 * 1024;
15pub const MAX_SUPERTESTS_PER_SUBMISSION: usize = 128;
16
17pub mod api {
18    pub const EXHAUSTED_ERROR_CODE: &str = "exhausted";
19    pub const AUTH_WHOAMI: &str = "/v1/super/auth/whoami";
20    pub const USAGE: &str = "/v1/super/usage";
21    pub const WORKSPACES: &str = "/v1/super/workspaces";
22
23    #[must_use]
24    pub fn workspace(id: impl std::fmt::Display) -> String {
25        format!("/v1/super/workspaces/{id}")
26    }
27
28    #[must_use]
29    pub fn workspace_revisions(id: impl std::fmt::Display) -> String {
30        format!("/v1/super/workspaces/{id}/revisions")
31    }
32
33    #[must_use]
34    pub fn workspace_revision(id: impl std::fmt::Display) -> String {
35        format!("/v1/super/workspace-revisions/{id}")
36    }
37
38    #[must_use]
39    pub fn content_upload(id: impl std::fmt::Display, sha256: &str) -> String {
40        format!("/v1/super/workspace-revisions/{id}/content/{sha256}/upload")
41    }
42
43    #[must_use]
44    pub fn verify_content(id: impl std::fmt::Display, sha256: &str) -> String {
45        format!("/v1/super/workspace-revisions/{id}/content/{sha256}/verify")
46    }
47
48    #[must_use]
49    pub fn finalize_revision(id: impl std::fmt::Display) -> String {
50        format!("/v1/super/workspace-revisions/{id}/finalize")
51    }
52
53    #[must_use]
54    pub fn check_submissions(id: impl std::fmt::Display) -> String {
55        format!("/v1/super/workspaces/{id}/check-submissions")
56    }
57
58    #[must_use]
59    pub fn latest_check_submission(id: impl std::fmt::Display) -> String {
60        format!("/v1/super/workspaces/{id}/check-submissions/latest")
61    }
62
63    #[must_use]
64    pub fn check_submission(id: impl std::fmt::Display, run: impl std::fmt::Display) -> String {
65        format!("/v1/super/workspaces/{id}/check-submissions/{run}")
66    }
67
68    #[must_use]
69    pub fn check_history(id: impl std::fmt::Display) -> String {
70        format!("/v1/super/workspaces/{id}/checks/history")
71    }
72
73    #[must_use]
74    pub fn checks(id: impl std::fmt::Display) -> String {
75        format!("/v1/super/workspaces/{id}/checks")
76    }
77
78    #[must_use]
79    pub fn check_events(id: impl std::fmt::Display) -> String {
80        format!("/v1/super/workspaces/{id}/check-events")
81    }
82
83    #[must_use]
84    pub fn check(id: impl std::fmt::Display, check_id: u64) -> String {
85        format!("/v1/super/workspaces/{id}/checks/{check_id}")
86    }
87
88    #[must_use]
89    pub fn fix(id: impl std::fmt::Display, check_id: u64) -> String {
90        format!("/v1/super/workspaces/{id}/checks/{check_id}/fix")
91    }
92
93    #[must_use]
94    pub fn cancel_check(id: impl std::fmt::Display, check_id: u64) -> String {
95        format!("/v1/super/workspaces/{id}/checks/{check_id}/cancel")
96    }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
100#[serde(deny_unknown_fields)]
101pub struct ApiError {
102    pub code: String,
103    pub message: String,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub help: Option<String>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109#[serde(deny_unknown_fields)]
110pub struct ApiErrorEnvelope {
111    pub error: ApiError,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
115#[serde(deny_unknown_fields)]
116pub struct Identity {
117    pub email: String,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121#[serde(deny_unknown_fields)]
122pub struct CreateWorkspace {
123    pub name: String,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
127#[serde(deny_unknown_fields)]
128pub struct Workspace {
129    pub id: Uuid,
130    pub name: String,
131    pub created_at: DateTime<Utc>,
132    pub updated_at: DateTime<Utc>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
136#[serde(deny_unknown_fields)]
137pub struct RevisionFile {
138    pub path: String,
139    pub sha256: String,
140    pub bytes: u64,
141    #[serde(default)]
142    pub executable: bool,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
146#[serde(deny_unknown_fields)]
147pub struct CreateWorkspaceRevision {
148    #[serde(default)]
149    pub parent_id: Option<Uuid>,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub reported_git_commit: Option<String>,
152    pub tree_sha256: String,
153    #[serde(default)]
154    pub files: Vec<RevisionFile>,
155}
156
157#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
158#[serde(rename_all = "snake_case")]
159pub enum WorkspaceRevisionState {
160    Uploading,
161    Materializing,
162    Complete,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
166#[serde(deny_unknown_fields)]
167pub struct WorkspaceRevision {
168    pub id: Uuid,
169    pub workspace_id: Uuid,
170    pub parent_id: Option<Uuid>,
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub reported_git_commit: Option<String>,
173    pub tree_sha256: String,
174    pub files: Vec<RevisionFile>,
175    pub state: WorkspaceRevisionState,
176    pub created_at: DateTime<Utc>,
177    pub completed_at: Option<DateTime<Utc>>,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
181#[serde(deny_unknown_fields)]
182pub struct WorkspaceDiff {
183    pub revision_id: Uuid,
184    pub parent_id: Option<Uuid>,
185    pub added: Vec<String>,
186    pub changed: Vec<String>,
187    pub removed: Vec<String>,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
191#[serde(deny_unknown_fields)]
192pub struct MissingContent {
193    pub sha256: String,
194    pub bytes: u64,
195    pub stored_bytes: u64,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
199#[serde(deny_unknown_fields)]
200pub struct WorkspaceRevisionAdmission {
201    pub revision: WorkspaceRevision,
202    pub diff: WorkspaceDiff,
203    pub missing_content: Vec<MissingContent>,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
207#[serde(deny_unknown_fields)]
208pub struct UploadPlan {
209    pub staged_upload_id: String,
210    pub sha256: String,
211    pub bytes: u64,
212    pub initiation_url: String,
213    pub method: String,
214    pub headers: std::collections::BTreeMap<String, String>,
215    pub expires_at: DateTime<Utc>,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
219#[serde(deny_unknown_fields)]
220pub struct UploadRequest {
221    pub bytes: u64,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
225#[serde(deny_unknown_fields)]
226pub struct VerifyUploadRequest {
227    pub staged_upload_id: String,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
231#[serde(deny_unknown_fields)]
232pub struct CommitRef {
233    pub oid: String,
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub branch: Option<String>,
236    #[serde(default)]
237    pub temporary: bool,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub parent_oid: Option<String>,
240}
241
242impl CommitRef {
243    #[must_use]
244    pub fn short_oid(&self) -> &str {
245        &self.oid[..self.oid.len().min(7)]
246    }
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
250#[serde(deny_unknown_fields)]
251pub struct SourceManifest {
252    pub tree_sha256: String,
253    pub files: Vec<SourceFile>,
254}
255
256impl SourceManifest {
257    /// Validates the exact complete-tree identity sent to Super API.
258    ///
259    /// # Errors
260    ///
261    /// Returns a stable diagnostic for a malformed path, digest, size, duplicate, or tree identity.
262    pub fn validate(&self) -> Result<(), String> {
263        if self.files.len() > MAX_SOURCE_FILES {
264            return Err(format!(
265                "a source manifest may contain at most {MAX_SOURCE_FILES} files"
266            ));
267        }
268        let mut paths = BTreeSet::new();
269        for file in &self.files {
270            if !valid_source_path(&file.path) {
271                return Err(format!("source path '{}' is not a normalized relative path", file.path));
272            }
273            if !paths.insert(&file.path) {
274                return Err(format!("source path '{}' appears more than once", file.path));
275            }
276            if !is_lower_sha256(&file.sha256) {
277                return Err(format!("source file '{}' has an invalid SHA-256 identity", file.path));
278            }
279            if file.bytes > MAX_SOURCE_FILE_BYTES {
280                return Err(format!(
281                    "source file '{}' exceeds the {} byte file-size limit",
282                    file.path, MAX_SOURCE_FILE_BYTES
283                ));
284            }
285        }
286        if self.tree_sha256 != source_tree_sha256(&self.files)? {
287            return Err("source tree SHA-256 does not match its complete file manifest".to_owned());
288        }
289        Ok(())
290    }
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
294#[serde(deny_unknown_fields)]
295pub struct SourceFile {
296    pub path: String,
297    pub sha256: String,
298    pub bytes: u64,
299    pub executable: bool,
300}
301
302#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
303#[serde(rename_all = "snake_case")]
304pub enum SourceLanguage {
305    C,
306    Rust,
307    Python,
308    #[serde(rename = "csharp")]
309    CSharp,
310    #[serde(rename = "javascript")]
311    JavaScript,
312    Java,
313    Vhdl,
314}
315
316impl std::fmt::Display for SourceLanguage {
317    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318        formatter.write_str(match self {
319            Self::C => "c",
320            Self::Rust => "rust",
321            Self::Python => "python",
322            Self::CSharp => "csharp",
323            Self::JavaScript => "javascript",
324            Self::Java => "java",
325            Self::Vhdl => "vhdl",
326        })
327    }
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
331#[serde(deny_unknown_fields)]
332pub struct Supertest {
333    pub path: String,
334    pub name: String,
335    pub language: SourceLanguage,
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub line: Option<u32>,
338}
339
340impl Supertest {
341    #[must_use]
342    pub fn selector(&self) -> String {
343        format!("{}::{}", self.path, self.name)
344    }
345}
346
347#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
348#[serde(rename_all = "snake_case")]
349pub enum FixProposalState {
350    Proposed,
351}
352
353#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
354#[serde(rename_all = "snake_case")]
355pub enum FixFileChange {
356    Added,
357    Modified,
358    Deleted,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
362#[serde(deny_unknown_fields)]
363pub struct FixFile {
364    pub path: String,
365    pub change: FixFileChange,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
369#[serde(deny_unknown_fields)]
370pub struct FixProposal {
371    pub id: Uuid,
372    pub state: FixProposalState,
373    pub base_revision_id: Uuid,
374    pub base_tree_sha256: String,
375    pub summary: String,
376    pub diff: String,
377    pub instructions: String,
378    pub files: Vec<FixFile>,
379    pub validation: Vec<String>,
380    pub created_at: DateTime<Utc>,
381}
382
383impl FixProposal {
384    pub const MAX_DIFF_BYTES: usize = 512 * 1024;
385    pub const MAX_FILES: usize = 64;
386    pub const MAX_VALIDATION_ITEMS: usize = 32;
387
388    /// Revalidates the bounded patch before the public tool applies it locally.
389    ///
390    /// # Errors
391    ///
392    /// Returns a diagnostic when identity, text, paths, or declared patch scope is malformed.
393    pub fn validate(&self) -> Result<(), String> {
394        if !is_lower_hex_len(&self.base_tree_sha256, 64) {
395            return Err("fix base tree identity is not a lowercase SHA-256 digest".into());
396        }
397        if self.summary.trim().is_empty() || self.summary.len() > 512 || self.summary.chars().any(char::is_control) {
398            return Err("fix summary must be one nonempty printable line of at most 512 bytes".into());
399        }
400        if self.diff.trim().is_empty()
401            || self.diff.len() > Self::MAX_DIFF_BYTES
402            || self.diff.contains('\0')
403            || self.diff.contains("GIT binary patch")
404            || self.diff.contains("Submodule ")
405        {
406            return Err("fix patch must be a bounded text diff without binary or submodule records".into());
407        }
408        let diff_paths = fix_diff_paths(&self.diff)?;
409        if self.files.is_empty() || self.files.len() > Self::MAX_FILES {
410            return Err("fix must declare between one and 64 changed files".into());
411        }
412        let mut declared = BTreeSet::new();
413        for file in &self.files {
414            if !valid_source_path(&file.path) || file.path.chars().any(char::is_control) {
415                return Err(format!("fix file path '{}' is not a safe relative path", file.path));
416            }
417            if !declared.insert(file.path.as_str()) || !diff_paths.contains(file.path.as_str()) {
418                return Err("fix file declarations must be unique and match the unified diff exactly".into());
419            }
420        }
421        if declared.len() != diff_paths.len() {
422            return Err("fix file declarations must match every unified-diff path exactly".into());
423        }
424        if self.instructions.trim().is_empty()
425            || self.instructions.len() > 16 * 1024
426            || self
427                .instructions
428                .chars()
429                .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
430        {
431            return Err("fix instructions must be printable text of at most 16 KiB".into());
432        }
433        if self.validation.len() > Self::MAX_VALIDATION_ITEMS
434            || self.validation.iter().any(|item| {
435                item.trim().is_empty()
436                    || item.len() > 512
437                    || item
438                        .chars()
439                        .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
440            })
441        {
442            return Err("fix validation commands must contain at most 32 printable items of at most 512 bytes".into());
443        }
444        Ok(())
445    }
446}
447
448#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
449#[serde(rename_all = "snake_case")]
450pub enum PresentationTone {
451    Success,
452    Warning,
453    Error,
454    Active,
455    Muted,
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
459#[serde(deny_unknown_fields)]
460pub struct StatusPresentation {
461    pub marker: String,
462    pub label: String,
463    pub tone: PresentationTone,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
467#[serde(deny_unknown_fields)]
468pub struct PresentationLine {
469    pub text: String,
470    pub emphasized: bool,
471}
472
473#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
474#[serde(deny_unknown_fields)]
475pub struct HistoryPresentation {
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub outcome_group: Option<String>,
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub collapse_group: Option<String>,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
483#[serde(deny_unknown_fields)]
484pub struct CheckPresentation {
485    pub status: StatusPresentation,
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub live_line: Option<String>,
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub activity_label: Option<String>,
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub activity_updated_at: Option<DateTime<Utc>>,
492    #[serde(default, skip_serializing_if = "Vec::is_empty")]
493    pub details: Vec<PresentationLine>,
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub active_problem_label: Option<String>,
496    pub history: HistoryPresentation,
497}
498
499/// Semantic result data; terminality and operational failures remain independent.
500#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
501#[serde(deny_unknown_fields)]
502pub struct CheckResult {
503    pub outcome: CheckOutcome,
504    pub assurance: CheckAssurance,
505}
506
507#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
508#[serde(rename_all = "snake_case")]
509pub enum CheckOutcome {
510    Pass,
511    Fail,
512    Conditional,
513}
514
515#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
516#[serde(rename_all = "snake_case")]
517pub enum CheckAssurance {
518    Certified,
519    Uncertified,
520}
521
522#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
523#[serde(deny_unknown_fields)]
524#[allow(clippy::struct_excessive_bools)] // Independent public result and delivery states.
525pub struct Check {
526    pub repository_id: Uuid,
527    pub number: u64,
528    pub supertest: Supertest,
529    pub revision: CheckRevision,
530    pub terminal: bool,
531    pub problematic: bool,
532    #[serde(default)]
533    pub result: Option<CheckResult>,
534    /// The check could not complete normally; distinct from a reported code problem.
535    #[serde(default)]
536    pub operational_error: Option<CheckOperationalError>,
537    pub presentation: CheckPresentation,
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub fix: Option<FixProposal>,
540    /// A proposal may still arrive; this does not indicate active preparation.
541    #[serde(default)]
542    pub fix_pending: bool,
543    /// Further updates may arrive even when `terminal` is true.
544    #[serde(default)]
545    pub updates_pending: bool,
546    #[serde(default)]
547    pub event_sequence: u64,
548    pub created_at: DateTime<Utc>,
549    pub updated_at: DateTime<Utc>,
550}
551
552#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
553#[serde(deny_unknown_fields)]
554pub struct CheckRevision {
555    pub id: Uuid,
556    pub tree_sha256: String,
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub reported_git_commit: Option<String>,
559}
560
561impl CheckRevision {
562    /// A compact display identity. The content tree remains authoritative; a reported Git OID is
563    /// only preferred when present because it is more familiar to a local Git user.
564    #[must_use]
565    pub fn short_identity(&self) -> &str {
566        let value = self.reported_git_commit.as_deref().unwrap_or(&self.tree_sha256);
567        &value[..value.len().min(7)]
568    }
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
572#[serde(deny_unknown_fields)]
573pub struct CheckEvent {
574    pub sequence: u64,
575    pub check: Check,
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
579#[serde(deny_unknown_fields)]
580pub struct CreateChecksRequest {
581    pub revision_id: Uuid,
582    pub selector: String,
583    #[serde(default)]
584    pub certify: bool,
585    pub supertests: Vec<Supertest>,
586}
587
588#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
589#[serde(deny_unknown_fields)]
590pub struct CheckSubmission {
591    pub id: Uuid,
592    pub repository_id: Uuid,
593    pub selector: String,
594    pub check_numbers: Vec<u64>,
595    pub created_at: DateTime<Utc>,
596}
597
598#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
599#[serde(deny_unknown_fields)]
600pub struct CheckSubmissionResponse {
601    pub submission: CheckSubmission,
602    pub checks: Vec<Check>,
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
606#[serde(deny_unknown_fields)]
607pub struct CheckHistoryPage {
608    pub checks: Vec<Check>,
609    pub next_before: Option<u64>,
610}
611
612#[must_use]
613pub fn source_sha256(bytes: &[u8]) -> String {
614    hex::encode(Sha256::digest(bytes))
615}
616
617/// Computes the canonical `pup-workspace-tree-v1` source identity accepted by Super API.
618///
619/// # Errors
620///
621/// Returns an error when a content digest or path cannot be encoded canonically.
622pub fn source_tree_sha256(files: &[SourceFile]) -> Result<String, String> {
623    let mut files = files.iter().collect::<Vec<_>>();
624    files.sort_by(|left, right| left.path.cmp(&right.path));
625    let mut digest = Sha256::new();
626    digest.update(b"pup-workspace-tree-v1\0");
627    for file in files {
628        if !is_lower_sha256(&file.sha256) {
629            return Err(format!("source file '{}' has an invalid SHA-256 identity", file.path));
630        }
631        let path = file.path.as_bytes();
632        let path_length = u32::try_from(path.len()).map_err(|_| "source path is too long".to_owned())?;
633        let mut content_digest = [0_u8; 32];
634        hex::decode_to_slice(&file.sha256, &mut content_digest)
635            .map_err(|_| format!("source file '{}' has an invalid SHA-256 identity", file.path))?;
636        digest.update(path_length.to_be_bytes());
637        digest.update(path);
638        digest.update(content_digest);
639        digest.update(file.bytes.to_be_bytes());
640        digest.update([u8::from(file.executable)]);
641    }
642    Ok(hex::encode(digest.finalize()))
643}
644
645fn valid_source_path(path: &str) -> bool {
646    !path.is_empty()
647        && !path.starts_with('/')
648        && !path.ends_with('/')
649        && !path
650            .split('/')
651            .any(|component| component.is_empty() || matches!(component, "." | ".."))
652}
653
654fn fix_diff_paths(diff: &str) -> Result<BTreeSet<&str>, String> {
655    let mut paths = BTreeSet::new();
656    for line in diff.lines() {
657        let Some(rest) = line.strip_prefix("diff --git a/") else {
658            continue;
659        };
660        let Some((left, right)) = rest.split_once(" b/") else {
661            return Err("each fix diff header must name matching a/ and b/ paths".into());
662        };
663        if left.is_empty() || left != right || !valid_source_path(left) {
664            return Err("fix diffs may not contain malformed paths or renames".into());
665        }
666        paths.insert(left);
667    }
668    if paths.is_empty() {
669        return Err("fix patch must contain at least one unified-diff header".into());
670    }
671    Ok(paths)
672}
673
674fn is_lower_hex(value: &str) -> bool {
675    value
676        .bytes()
677        .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
678}
679
680fn is_lower_hex_len(value: &str, length: usize) -> bool {
681    value.len() == length && is_lower_hex(value)
682}
683
684fn is_lower_sha256(value: &str) -> bool {
685    value.len() == 64
686        && value
687            .bytes()
688            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    #[test]
696    fn client_routes_use_the_versioned_pup_namespace() {
697        let id = Uuid::nil();
698        assert_eq!(api::AUTH_WHOAMI, "/v1/super/auth/whoami");
699        assert_eq!(api::WORKSPACES, "/v1/super/workspaces");
700        for route in [
701            api::workspace(id),
702            api::workspace_revisions(id),
703            api::workspace_revision(id),
704            api::content_upload(id, "digest"),
705            api::verify_content(id, "digest"),
706            api::finalize_revision(id),
707            api::check_submissions(id),
708            api::latest_check_submission(id),
709            api::checks(id),
710            api::check_events(id),
711            api::check(id, 1),
712            api::fix(id, 1),
713            api::cancel_check(id, 1),
714        ] {
715            assert!(route.starts_with("/v1/super/"), "unexpected route: {route}");
716        }
717    }
718
719    #[test]
720    fn public_status_text_is_deserialized_without_interpretation() {
721        let presentation: CheckPresentation = serde_json::from_value(serde_json::json!({
722            "status": {
723                "marker": "!",
724                "label": "server-owned wording",
725                "tone": "warning"
726            },
727            "details": [{"text": "server-owned evidence", "emphasized": true}],
728            "history": {"outcome_group": "opaque"}
729        }))
730        .expect("public presentation");
731        assert_eq!(presentation.status.label, "server-owned wording");
732        assert_eq!(presentation.details[0].text, "server-owned evidence");
733    }
734
735    #[test]
736    fn public_check_admission_exposes_intent_but_no_internal_profile() {
737        let request = CreateChecksRequest {
738            revision_id: Uuid::nil(),
739            selector: "tests/example.c::identity_holds".to_owned(),
740            certify: false,
741            supertests: Vec::new(),
742        };
743        let value = serde_json::to_value(request).unwrap();
744        assert_eq!(value["certify"], false);
745        assert!(value.get("profile").is_none());
746        assert!(value.get("assurance").is_none());
747    }
748
749    #[test]
750    fn workspace_revision_uses_optional_reported_git_provenance() {
751        let request = CreateWorkspaceRevision {
752            parent_id: None,
753            reported_git_commit: Some("a".repeat(40)),
754            tree_sha256: "b".repeat(64),
755            files: Vec::new(),
756        };
757        let value = serde_json::to_value(request).unwrap();
758        assert_eq!(value["reported_git_commit"], "a".repeat(40));
759        assert!(value.get("git_commit").is_none());
760
761        assert!(
762            serde_json::from_value::<CreateWorkspaceRevision>(serde_json::json!({
763                "git_commit": "a".repeat(40),
764                "tree_sha256": "b".repeat(64),
765                "files": []
766            }))
767            .is_err()
768        );
769    }
770
771    #[test]
772    fn check_revision_falls_back_to_the_authoritative_tree_for_display() {
773        let revision = CheckRevision {
774            id: Uuid::nil(),
775            tree_sha256: "1234567890abcdef".repeat(4),
776            reported_git_commit: None,
777        };
778        assert_eq!(revision.short_identity(), "1234567");
779    }
780
781    #[test]
782    fn short_oid_handles_sha1_and_tiny_test_ids() {
783        let commit = CommitRef {
784            oid: "a81d7c2ff00".into(),
785            branch: None,
786            temporary: false,
787            parent_oid: None,
788        };
789        assert_eq!(commit.short_oid(), "a81d7c2");
790    }
791
792    #[test]
793    fn workspace_tree_identity_uses_bytewise_utf8_path_order() {
794        let files = [
795            SourceFile {
796                path: "a/b".into(),
797                sha256: "22".repeat(32),
798                bytes: 2,
799                executable: true,
800            },
801            SourceFile {
802                path: "a-b".into(),
803                sha256: "11".repeat(32),
804                bytes: 1,
805                executable: false,
806            },
807        ];
808        assert_eq!(
809            source_tree_sha256(&files).expect("bytewise UTF-8 order"),
810            "1164facf7b1210ed7a8749455048ef9a0d0e39179fa0c85c57720c6395a5c83e"
811        );
812    }
813
814    #[test]
815    fn fix_proposals_validate_the_declared_patch_scope() {
816        let proposal = FixProposal {
817            id: Uuid::new_v4(),
818            state: FixProposalState::Proposed,
819            base_revision_id: Uuid::new_v4(),
820            base_tree_sha256: "b".repeat(64),
821            summary: "Guard the empty value".into(),
822            diff: "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1 @@\n-old\n+new\n"
823                .into(),
824            instructions: "Apply and run the focused test.".into(),
825            files: vec![FixFile {
826                path: "src/lib.rs".into(),
827                change: FixFileChange::Modified,
828            }],
829            validation: vec!["cargo test".into()],
830            created_at: Utc::now(),
831        };
832        assert!(proposal.validate().is_ok());
833
834        let mut mismatched = proposal.clone();
835        mismatched.files[0].path = "src/other.rs".into();
836        assert!(mismatched.validate().is_err());
837
838        let mut traversal = proposal;
839        traversal.diff = traversal.diff.replace("src/lib.rs", "../outside.rs");
840        assert!(traversal.validate().is_err());
841    }
842}
843
844#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
845#[serde(rename_all = "snake_case")]
846pub enum CheckOperationalError {
847    Blocked,
848    Canceled,
849    Error,
850    MissingConclusion,
851}