1pub 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 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 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#[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)] pub 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 #[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 #[serde(default)]
557 pub fix_pending: bool,
558 #[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 #[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
634pub 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}