1pub 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 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 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#[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)] pub 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 #[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 #[serde(default)]
536 pub fix_pending: bool,
537 #[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 #[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
611pub 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}