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