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