Skip to main content

buntrail_protocol/
types.rs

1pub use crate::path::{PathKind, WirePath};
2use base64::{Engine as _, engine::general_purpose::STANDARD};
3use serde::{Deserialize, Serialize};
4#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(deny_unknown_fields)]
6pub struct Context {
7    pub workspace_id: String,
8    pub client_id: String,
9    pub target_binding: String,
10    pub runtime_generation: String,
11}
12#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct Header {
15    pub major: u32,
16    pub minor: u32,
17    pub request_id: u64,
18    #[serde(deserialize_with = "required_option")]
19    pub context: Option<Context>,
20    #[serde(deserialize_with = "required_option")]
21    pub operation_id: Option<String>,
22    #[serde(deserialize_with = "required_option")]
23    pub stream_id: Option<String>,
24    #[serde(deserialize_with = "required_option")]
25    pub sequence: Option<u64>,
26    #[serde(flatten)]
27    pub message: Message,
28}
29#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(tag = "kind", content = "metadata", deny_unknown_fields)]
31pub enum Message {
32    #[serde(rename = "proof.lookup.begin")]
33    ProofLookupBegin {
34        transfer_id: String,
35        total_length: u64,
36        action: ProofAction,
37        lease_id: String,
38    },
39    #[serde(rename = "proof.lookup.ready")]
40    ProofLookupReady { credit: u32 },
41    #[serde(rename = "proof.ready")]
42    ProofReady {
43        original_transfer_id: String,
44        approvals_transfer_id: String,
45    },
46    #[serde(rename = "proof.observe.begin")]
47    ProofObserveBegin {
48        transfer_id: String,
49        total_length: u64,
50        lease_id: String,
51        worktree: crate::services::Worktree,
52    },
53    #[serde(rename = "proof.approvals.begin")]
54    ProofApprovalsBegin {
55        transfer_id: String,
56        total_length: u64,
57        proof_id: String,
58        alias_digest: String,
59    },
60    #[serde(rename = "proof.output.begin")]
61    ProofOutputBegin {
62        transfer_id: String,
63        total_length: u64,
64    },
65    #[serde(rename = "proof.prepared")]
66    ProofPrepared { plan_id: String },
67    #[serde(rename = "proof.existing")]
68    ProofExisting { receipt: crate::services::Receipt },
69    #[serde(rename = "proof.missing")]
70    ProofMissing {},
71    #[serde(rename = "proof.cancel")]
72    ProofCancel { lease_id: String, plan_id: String },
73    #[serde(rename = "proof.release")]
74    ProofRelease {
75        lease_id: String,
76        observation_id: String,
77    },
78    #[serde(rename = "proof.lookup.chunk")]
79    ProofLookupChunk { transfer_id: String, offset: u64 },
80    #[serde(rename = "proof.lookup.end")]
81    ProofLookupEnd { transfer_id: String, sha256: String },
82    #[serde(rename = "proof.original.chunk")]
83    ProofOriginalChunk { transfer_id: String, offset: u64 },
84    #[serde(rename = "proof.original.end")]
85    ProofOriginalEnd { transfer_id: String, sha256: String },
86    #[serde(rename = "proof.approvals.chunk")]
87    ProofApprovalsChunk { transfer_id: String, offset: u64 },
88    #[serde(rename = "proof.approvals.end")]
89    ProofApprovalsEnd { transfer_id: String, sha256: String },
90    #[serde(rename = "proof.aliases.chunk")]
91    ProofAliasesChunk { transfer_id: String, offset: u64 },
92    #[serde(rename = "proof.aliases.end")]
93    ProofAliasesEnd { transfer_id: String, sha256: String },
94    #[serde(rename = "proof.output.chunk")]
95    ProofOutputChunk { transfer_id: String, offset: u64 },
96    #[serde(rename = "proof.output.end")]
97    ProofOutputEnd { transfer_id: String, sha256: String },
98    #[serde(rename = "hello")]
99    Hello {
100        capabilities: Vec<String>,
101        max_header: u32,
102        max_payload: u32,
103    },
104    #[serde(rename = "welcome")]
105    Welcome {
106        capabilities: Vec<String>,
107        max_header: u32,
108        max_payload: u32,
109        initial_credit: u32,
110        target_binding: String,
111        runtime_generation: String,
112        #[serde(default, skip_serializing_if = "Option::is_none")]
113        container_start_base64: Option<String>,
114    },
115    #[serde(rename = "lease.register")]
116    Register {
117        lease_id: String,
118        client_id: String,
119        workspace_id: String,
120        target_binding: String,
121        runtime_generation: String,
122    },
123    #[serde(rename = "lease.release")]
124    Release {
125        lease_id: String,
126        intent: ReleaseIntent,
127    },
128    #[serde(rename = "connection.authentication")]
129    Authentication {
130        #[serde(deserialize_with = "required_option")]
131        ssh_auth_sock: Option<String>,
132    },
133    #[serde(rename = "ack")]
134    Ack {},
135    #[serde(rename = "stream.credit")]
136    Credit { bytes: u32 },
137    #[serde(rename = "file.read")]
138    Read { path_base64: String },
139    #[serde(rename = "operation.receipt")]
140    Receipt {
141        state: ReceiptState,
142        #[serde(deserialize_with = "required_option")]
143        saved_version: Option<WireVersion>,
144        #[serde(deserialize_with = "required_option")]
145        saved_revision: Option<u64>,
146        safe_message: String,
147        #[serde(deserialize_with = "required_option")]
148        failure: Option<WireFailure>,
149        observed: WireObservation,
150    },
151    #[serde(rename = "cancel.result")]
152    Cancellation { outcome: CancelOutcome },
153    #[serde(rename = "service.call")]
154    ServiceCall { call: crate::services::Call },
155    #[serde(rename = "service.event")]
156    ServiceEvent {
157        offset: u64,
158        total_length: u64,
159        sha256: String,
160        terminal: bool,
161    },
162    #[serde(rename = "file.list")]
163    FileList { path_base64: String },
164    #[serde(rename = "file.read.begin")]
165    ReadBegin {
166        transfer_id: String,
167        path_base64: String,
168        version: String,
169        total_length: u64,
170    },
171    #[serde(rename = "file.read.chunk")]
172    ReadChunk { transfer_id: String, offset: u64 },
173    #[serde(rename = "file.read.end")]
174    ReadEnd { transfer_id: String, sha256: String },
175    #[serde(rename = "file.upload.begin")]
176    UploadBegin {
177        transfer_id: String,
178        path_base64: String,
179        total_length: u64,
180    },
181    #[serde(rename = "file.upload.chunk")]
182    UploadChunk { transfer_id: String, offset: u64 },
183    #[serde(rename = "file.upload.end")]
184    UploadEnd { transfer_id: String, sha256: String },
185    #[serde(rename = "file.create.commit")]
186    CreateCommit { transfer_id: String },
187    #[serde(rename = "file.save.commit")]
188    SaveCommit {
189        transfer_id: String,
190        submitted_revision: u64,
191        #[serde(deserialize_with = "required_option")]
192        expected_version: Option<String>,
193    },
194    #[serde(rename = "file.save.commit_owned")]
195    SaveCommitOwned {
196        transfer_id: String,
197        lease_id: String,
198        submitted_revision: u64,
199        #[serde(deserialize_with = "required_option")]
200        expected_version: Option<String>,
201    },
202    #[serde(rename = "operation.inspect")]
203    Inspect { operation_id: String },
204    #[serde(rename = "terminal.output")]
205    TerminalOutput { session_id: String },
206    #[serde(rename = "cancel")]
207    Cancel { request_id: u64 },
208    #[serde(rename = "error")]
209    Error { code: String, safe_message: String },
210}
211#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(rename_all = "snake_case")]
213pub enum ProofAction {
214    Lookup,
215    Prepare,
216    Execute,
217}
218#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum ReleaseIntent {
221    ViewDetachment,
222    ResolvedWorkspaceClosure,
223}
224#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(rename_all = "snake_case")]
226pub enum ReceiptState {
227    Accepted,
228    Running,
229    Confirmed,
230    Failed,
231    Unknown,
232}
233#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(deny_unknown_fields)]
235pub struct WireVersion {
236    pub opaque_token: String,
237    pub content_digest: String,
238    pub file_identity: String,
239}
240#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(rename_all = "snake_case")]
242pub enum CancelOutcome {
243    ConfirmedCancel,
244    Unknown,
245}
246#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(rename_all = "snake_case")]
248pub enum WireEffect {
249    ProvenNoEffect,
250    PossiblePartialEffects,
251}
252#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(deny_unknown_fields)]
254pub struct WireFailure {
255    pub code: String,
256    pub effect: WireEffect,
257}
258#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
259#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
260pub enum WireObservation {
261    Unobserved,
262    File {
263        path_base64: String,
264        #[serde(deserialize_with = "required_option")]
265        version: Option<WireVersion>,
266        #[serde(deserialize_with = "required_option")]
267        digest: Option<String>,
268    },
269}
270pub fn decode_path(value: &str) -> Result<Vec<u8>, &'static str> {
271    let bytes = STANDARD.decode(value).map_err(|_| "invalid base64 path")?;
272    if bytes.first() != Some(&b'/') || bytes.contains(&0) {
273        return Err("invalid absolute POSIX path");
274    }
275    Ok(bytes)
276}
277pub fn uuid(value: &str) -> bool {
278    value.len() == 36
279        && value.bytes().enumerate().all(|(i, c)| {
280            if [8, 13, 18, 23].contains(&i) {
281                c == b'-'
282            } else {
283                c.is_ascii_hexdigit()
284            }
285        })
286}
287impl Header {
288    pub fn validate(&self, payload: usize, expected: Option<&Context>) -> Result<(), &'static str> {
289        if self.major != 1 {
290            return Err("unsupported major");
291        }
292        if self.request_id == 0 {
293            return Err("missing request ID");
294        }
295        if let Message::Hello {
296            max_header,
297            max_payload,
298            ..
299        } = &self.message
300        {
301            if self.context.is_some()
302                || self.operation_id.is_some()
303                || self.stream_id.is_some()
304                || self.sequence.is_some()
305                || payload != 0
306                || *max_header == 0
307                || *max_header > 65536
308                || *max_payload == 0
309                || *max_payload > 1048576
310            {
311                return Err("invalid negotiation limits");
312            }
313            return Ok(());
314        }
315        if let Message::Welcome {
316            max_header,
317            max_payload,
318            initial_credit,
319            target_binding,
320            runtime_generation,
321            container_start_base64,
322            capabilities,
323            ..
324        } = &self.message
325        {
326            if self.context.is_some()
327                || self.operation_id.is_some()
328                || self.stream_id.is_some()
329                || self.sequence.is_some()
330                || payload != 0
331                || *max_header == 0
332                || *max_header > 65536
333                || *max_payload == 0
334                || *max_payload > 1048576
335                || *initial_credit != 524288
336                || target_binding.is_empty()
337                || !uuid(runtime_generation)
338                || decode_container_start(container_start_base64.as_deref()).is_err()
339                || container_start_base64.is_some()
340                    && !capabilities.iter().any(|c| c == "container.binding.v1")
341            {
342                return Err("invalid welcome");
343            }
344            return Ok(());
345        }
346        if let Message::Register {
347            lease_id,
348            client_id,
349            workspace_id,
350            target_binding,
351            runtime_generation,
352        } = &self.message
353        {
354            if self.context.is_some()
355                || self.operation_id.is_some()
356                || self.stream_id.is_some()
357                || self.sequence.is_some()
358                || payload != 0
359                || !uuid(lease_id)
360                || !uuid(client_id)
361                || !uuid(workspace_id)
362                || target_binding.is_empty()
363                || !uuid(runtime_generation)
364            {
365                return Err("invalid registration");
366            }
367            // The supervisor verifies the binding/runtime against its own identity before registration.
368            return Ok(());
369        }
370        let context = self.context.as_ref().ok_or("missing scoped context")?;
371        if Some(context) != expected {
372            return Err("mismatched authenticated context");
373        }
374        if !uuid(&context.workspace_id)
375            || !uuid(&context.client_id)
376            || context.target_binding.is_empty()
377            || context.runtime_generation.is_empty()
378        {
379            return Err("invalid scoped IDs");
380        }
381        for id in [&self.operation_id, &self.stream_id].into_iter().flatten() {
382            if !uuid(id) {
383                return Err("invalid ID");
384            }
385        }
386        let raw = matches!(
387            self.message,
388            Message::ServiceEvent { .. }
389                | Message::ProofLookupChunk { .. }
390                | Message::ProofOriginalChunk { .. }
391                | Message::ProofApprovalsChunk { .. }
392                | Message::ProofAliasesChunk { .. }
393                | Message::ProofOutputChunk { .. }
394                | Message::ReadChunk { .. }
395                | Message::UploadChunk { .. }
396                | Message::TerminalOutput { .. }
397                | Message::ServiceCall {
398                    call: crate::services::Call::TerminalInput { .. }
399                }
400        );
401        if payload != 0 && !raw {
402            return Err("unexpected payload");
403        }
404        if raw && payload > 65536 {
405            return Err("chunk too large");
406        }
407        match &self.message {
408            Message::ProofLookupBegin {
409                transfer_id,
410                lease_id,
411                ..
412            } => {
413                if !uuid(lease_id)
414                    || self.operation_id.is_none()
415                    || self.sequence.is_some()
416                    || self.stream_id.as_ref() != Some(transfer_id)
417                {
418                    return Err("invalid proof lookup identity");
419                }
420            }
421            Message::ProofObserveBegin {
422                transfer_id,
423                lease_id,
424                ..
425            } => {
426                if !uuid(lease_id)
427                    || self.operation_id.is_some()
428                    || self.sequence.is_some()
429                    || self.stream_id.as_ref() != Some(transfer_id)
430                {
431                    return Err("invalid observation identity");
432                }
433            }
434            Message::ProofApprovalsBegin {
435                transfer_id,
436                proof_id,
437                alias_digest,
438                ..
439            } => {
440                if !uuid(proof_id)
441                    || !hex_digest(alias_digest)
442                    || self.operation_id.is_none()
443                    || self.sequence.is_some()
444                    || self.stream_id.as_ref() != Some(transfer_id)
445                {
446                    return Err("invalid approval identity");
447                }
448            }
449            Message::ProofLookupReady { credit }
450                if *credit != 65536 || self.stream_id.is_none() || self.operation_id.is_none() =>
451            {
452                return Err("invalid lookup credit");
453            }
454            Message::ProofReady {
455                original_transfer_id,
456                approvals_transfer_id,
457            } => {
458                if !uuid(original_transfer_id)
459                    || !uuid(approvals_transfer_id)
460                    || original_transfer_id == approvals_transfer_id
461                    || self.operation_id.is_none()
462                {
463                    return Err("invalid proof ready identity");
464                }
465            }
466            Message::ProofOutputBegin { transfer_id, .. }
467                if self.stream_id.as_ref() != Some(transfer_id) || self.operation_id.is_some() =>
468            {
469                return Err("invalid proof output identity");
470            }
471            Message::ProofPrepared { plan_id } if !uuid(plan_id) || self.operation_id.is_none() => {
472                return Err("invalid plan identity");
473            }
474            Message::ProofExisting { .. } if self.operation_id.is_none() => {
475                return Err("missing proof receipt operation");
476            }
477            Message::ProofMissing {}
478                if self.operation_id.is_none()
479                    || self.stream_id.is_none()
480                    || self.sequence.is_some() =>
481            {
482                return Err("invalid missing-original reply identity");
483            }
484            Message::ProofCancel { lease_id, plan_id } => {
485                if !uuid(lease_id)
486                    || !uuid(plan_id)
487                    || self.operation_id.is_some()
488                    || self.stream_id.is_some()
489                    || self.sequence.is_some()
490                {
491                    return Err("invalid plan cancellation");
492                }
493            }
494            Message::ProofRelease {
495                lease_id,
496                observation_id,
497            } => {
498                if !uuid(lease_id)
499                    || !uuid(observation_id)
500                    || self.operation_id.is_some()
501                    || self.stream_id.is_some()
502                    || self.sequence.is_some()
503                {
504                    return Err("invalid observation release");
505                }
506            }
507            Message::ProofLookupChunk { transfer_id, .. }
508            | Message::ProofOriginalChunk { transfer_id, .. }
509            | Message::ProofApprovalsChunk { transfer_id, .. }
510            | Message::ProofAliasesChunk { transfer_id, .. }
511            | Message::ProofOutputChunk { transfer_id, .. } => {
512                if self.stream_id.as_ref() != Some(transfer_id)
513                    || self.sequence.is_none()
514                    || payload == 0
515                    || payload > 65536
516                {
517                    return Err("invalid proof frame");
518                }
519                if matches!(
520                    self.message,
521                    Message::ProofLookupChunk { .. }
522                        | Message::ProofOriginalChunk { .. }
523                        | Message::ProofApprovalsChunk { .. }
524                ) && self.operation_id.is_none()
525                {
526                    return Err("missing original operation");
527                }
528            }
529            Message::ProofLookupEnd {
530                transfer_id,
531                sha256,
532            }
533            | Message::ProofOriginalEnd {
534                transfer_id,
535                sha256,
536            }
537            | Message::ProofApprovalsEnd {
538                transfer_id,
539                sha256,
540            }
541            | Message::ProofAliasesEnd {
542                transfer_id,
543                sha256,
544            }
545            | Message::ProofOutputEnd {
546                transfer_id,
547                sha256,
548            } => {
549                if self.stream_id.as_ref() != Some(transfer_id)
550                    || self.sequence.is_none()
551                    || !hex_digest(sha256)
552                {
553                    return Err("invalid proof frame");
554                }
555                if matches!(
556                    self.message,
557                    Message::ProofLookupEnd { .. }
558                        | Message::ProofOriginalEnd { .. }
559                        | Message::ProofApprovalsEnd { .. }
560                ) && self.operation_id.is_none()
561                {
562                    return Err("missing original operation");
563                }
564            }
565            Message::Authentication {
566                ssh_auth_sock: Some(path),
567            } => {
568                decode_path(path)?;
569            }
570            Message::ServiceCall {
571                call: crate::services::Call::LeaseObserve { owner },
572            } => {
573                owner.validate().map_err(|_| "invalid observed owner")?;
574                if self.operation_id.is_some()
575                    || self.stream_id.is_some()
576                    || self.sequence.is_some()
577                {
578                    return Err("unexpected observation metadata");
579                }
580            }
581            Message::ServiceCall { call }
582                if (call.mutating()
583                    || matches!(
584                        call,
585                        crate::services::Call::Inspect | crate::services::Call::GitExisting { .. }
586                    ))
587                    && self.operation_id.is_none() =>
588            {
589                return Err("missing service operation ID");
590            }
591            Message::ServiceEvent {
592                offset,
593                total_length,
594                sha256,
595                ..
596            } if self.stream_id.is_none()
597                || self.sequence.is_none()
598                || *total_length > 32 * 1024 * 1024
599                || offset.saturating_add(payload as u64) > *total_length
600                || sha256.len() != 64
601                || !sha256.bytes().all(|b| b.is_ascii_hexdigit()) =>
602            {
603                return Err("invalid service event");
604            }
605            Message::Release { lease_id, .. } if !uuid(lease_id) => return Err("invalid lease ID"),
606            Message::Credit { bytes }
607                if *bytes == 0 || *bytes > 524288 || self.stream_id.is_none() =>
608            {
609                return Err("invalid stream credit");
610            }
611            Message::Receipt { .. } if self.operation_id.is_none() => {
612                return Err("missing receipt operation");
613            }
614            Message::Cancel { request_id: 0 } => return Err("invalid cancellation target ID"),
615            Message::ReadBegin {
616                transfer_id,
617                path_base64,
618                ..
619            }
620            | Message::UploadBegin {
621                transfer_id,
622                path_base64,
623                ..
624            } => {
625                if !uuid(transfer_id) {
626                    return Err("invalid transfer ID");
627                }
628                decode_path(path_base64)?;
629            }
630            Message::FileList { path_base64 } | Message::Read { path_base64 } => {
631                decode_path(path_base64)?;
632            }
633            Message::ReadChunk { transfer_id, .. }
634            | Message::UploadChunk { transfer_id, .. }
635            | Message::CreateCommit { transfer_id }
636            | Message::SaveCommit { transfer_id, .. } => {
637                if !uuid(transfer_id) {
638                    return Err("invalid transfer ID");
639                }
640            }
641            Message::SaveCommitOwned {
642                transfer_id,
643                lease_id,
644                ..
645            } => {
646                if !uuid(transfer_id) || !uuid(lease_id) {
647                    return Err("invalid owned save transfer or lease ID");
648                }
649            }
650            Message::ReadEnd {
651                transfer_id,
652                sha256,
653            }
654            | Message::UploadEnd {
655                transfer_id,
656                sha256,
657            } => {
658                if !uuid(transfer_id)
659                    || sha256.len() != 64
660                    || !sha256.bytes().all(|b| b.is_ascii_hexdigit())
661                {
662                    return Err("invalid transfer digest");
663                }
664            }
665            Message::Inspect { operation_id } => {
666                if !uuid(operation_id) || self.operation_id.as_ref() != Some(operation_id) {
667                    return Err("mismatched operation ID");
668                }
669            }
670            Message::TerminalOutput { session_id }
671                if (!uuid(session_id) || self.sequence.is_none() || self.stream_id.is_none()) =>
672            {
673                return Err("invalid terminal stream");
674            }
675            _ => {}
676        }
677        if matches!(
678            self.message,
679            Message::UploadBegin { .. }
680                | Message::UploadChunk { .. }
681                | Message::UploadEnd { .. }
682                | Message::CreateCommit { .. }
683                | Message::SaveCommit { .. }
684                | Message::SaveCommitOwned { .. }
685        ) && self.operation_id.is_none()
686        {
687            return Err("missing operation ID");
688        }
689        Ok(())
690    }
691}
692pub fn decode_container_start(value: Option<&str>) -> Result<Option<Vec<u8>>, &'static str> {
693    value
694        .map(|s| {
695            if s.len() > 344 {
696                return Err("container start generation exceeded its bound");
697            }
698            let bytes = STANDARD
699                .decode(s)
700                .map_err(|_| "invalid container start generation")?;
701            if bytes.is_empty() || bytes.len() > 256 {
702                return Err("invalid container start generation length");
703            }
704            Ok(bytes)
705        })
706        .transpose()
707}
708
709pub(crate) fn required_option<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
710where
711    D: serde::Deserializer<'de>,
712    T: Deserialize<'de>,
713{
714    Option::<T>::deserialize(deserializer)
715}
716
717fn hex_digest(s: &str) -> bool {
718    s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
719}