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