1pub 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 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 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#[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)] pub 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 #[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 #[serde(default)]
540 pub fix_pending: bool,
541 #[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 #[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
615pub 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}