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