1use std::fmt;
2use std::str::FromStr;
3
4use chrono::{DateTime, NaiveDate, Utc};
5use serde::{Deserialize, Serialize};
6use url::Url;
7
8use crate::validation::{bounded, collection_len, identifier, purpose, required};
9use crate::{AccessList, ApiError, IdentityId, OrgId, ProfileId, SessionId, Validate, ValidationError};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum IdentityKind {
14 Carbon,
15 Silicon,
16}
17
18#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
19pub struct Identity {
20 pub id: IdentityId,
21 pub name: String,
22 pub kind: IdentityKind,
23 #[serde(default, skip_serializing_if = "Vec::is_empty")]
24 pub tags: Vec<String>,
25 #[doc(hidden)]
29 #[serde(skip)]
30 pub verified_aliases: Vec<IdentityId>,
31}
32
33impl Identity {
34 pub fn matches_principal(&self, principal_id: &str) -> bool {
35 same_principal(&self.id, principal_id)
36 || self.verified_aliases.iter().any(|alias| same_principal(alias, principal_id))
37 }
38
39 pub fn principal_ids(&self) -> impl Iterator<Item = &str> {
40 std::iter::once(self.id.as_str()).chain(self.verified_aliases.iter().map(String::as_str))
41 }
42}
43
44#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
45pub struct Org {
46 pub id: OrgId,
47 pub name: String,
48}
49
50#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct AuthExchangeRequest {
54 #[serde(alias = "token")]
55 pub short_lived_token: String,
56 pub org_id: OrgId,
60}
61
62impl fmt::Debug for AuthExchangeRequest {
63 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
64 formatter
65 .debug_struct("AuthExchangeRequest")
66 .field("short_lived_token", &"[REDACTED]")
67 .field("org_id", &self.org_id)
68 .finish()
69 }
70}
71
72impl Validate for AuthExchangeRequest {
73 fn validate(&self) -> Result<(), ValidationError> {
74 opaque_auth_token(&self.short_lived_token, "oac_", "short_lived_token")?;
75 identifier(&self.org_id, "org_id")?;
76 Ok(())
77 }
78}
79
80#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct AuthRefreshRequest {
83 pub refresh_token: String,
84 pub org_id: OrgId,
86}
87
88impl fmt::Debug for AuthRefreshRequest {
89 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90 formatter
91 .debug_struct("AuthRefreshRequest")
92 .field("refresh_token", &"[REDACTED]")
93 .field("org_id", &self.org_id)
94 .finish()
95 }
96}
97
98impl Validate for AuthRefreshRequest {
99 fn validate(&self) -> Result<(), ValidationError> {
100 opaque_auth_token(&self.refresh_token, "ort_", "refresh_token")?;
101 identifier(&self.org_id, "org_id")?;
102 Ok(())
103 }
104}
105
106#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct AuthSession {
109 pub access_token: String,
110 pub refresh_token: String,
111 pub expires_at: DateTime<Utc>,
112 pub identity: Identity,
113 pub org: Org,
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub services: Vec<String>,
116}
117
118pub type AuthRefreshResponse = AuthSession;
119
120impl fmt::Debug for AuthSession {
121 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122 formatter
123 .debug_struct("AuthSession")
124 .field("access_token", &"[REDACTED]")
125 .field("refresh_token", &"[REDACTED]")
126 .field("expires_at", &self.expires_at)
127 .field("identity", &self.identity)
128 .field("org", &self.org)
129 .field("services", &self.services)
130 .finish()
131 }
132}
133
134impl Validate for AuthSession {
135 fn validate(&self) -> Result<(), ValidationError> {
136 opaque_auth_token(&self.access_token, "oat_", "access_token")?;
137 opaque_auth_token(&self.refresh_token, "ort_", "refresh_token")?;
138 identifier(&self.identity.id, "identity.id")?;
139 identifier(&self.org.id, "org.id")?;
140 for service in &self.services {
141 identifier(service, "services")?;
142 }
143 Ok(())
144 }
145}
146
147fn opaque_auth_token(value: &str, prefix: &'static str, field: &'static str) -> Result<(), ValidationError> {
148 if !value.starts_with(prefix)
149 || value.len() == prefix.len()
150 || value.len() > 16 * 1024
151 || value.chars().any(|character| character.is_whitespace() || character.is_control())
152 {
153 return Err(ValidationError::Invalid {
154 field,
155 reason: format!("expected one bounded {prefix} token without whitespace or controls"),
156 });
157 }
158 Ok(())
159}
160
161#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
162pub struct ProxyLocation {
163 pub code: String,
164 pub name: String,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub country: Option<String>,
167}
168
169impl Validate for ProxyLocation {
170 fn validate(&self) -> Result<(), ValidationError> {
171 required(&self.code, "location.code")?;
172 required(&self.name, "location.name")
173 }
174}
175
176#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "snake_case")]
178pub enum ProfileStatus {
179 Active,
180 Retired,
181}
182
183#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
184pub struct Profile {
185 pub id: ProfileId,
186 pub name: String,
187 pub fingerprint: String,
188 pub location: ProxyLocation,
189 pub access: AccessList,
190 pub owner_id: IdentityId,
191 pub sessions_run: u64,
192 pub status: ProfileStatus,
193 pub created_at: DateTime<Utc>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub ended_at: Option<DateTime<Utc>>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub end_note: Option<String>,
198}
199
200#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct ProfileCreate {
203 pub name: String,
204 pub location: String,
205 #[serde(default)]
206 pub access: AccessList,
207}
208
209impl Validate for ProfileCreate {
210 fn validate(&self) -> Result<(), ValidationError> {
211 bounded(&self.name, "name", 100)?;
212 if self.location.len() != 2 || !self.location.bytes().all(|byte| byte.is_ascii_alphabetic()) {
213 return Err(ValidationError::Invalid {
214 field: "location",
215 reason: "expected a two-letter provider location code".into(),
216 });
217 }
218 self.access.validate()
219 }
220}
221
222#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(deny_unknown_fields)]
224pub struct ProfileUpdate {
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub name: Option<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub access: Option<AccessList>,
229}
230
231impl Validate for ProfileUpdate {
232 fn validate(&self) -> Result<(), ValidationError> {
233 if self.name.is_none() && self.access.is_none() {
234 return Err(ValidationError::Required { field: "name or access" });
235 }
236 if let Some(name) = &self.name {
237 bounded(name, "name", 100)?;
238 }
239 if let Some(access) = &self.access {
240 access.validate()?;
241 }
242 Ok(())
243 }
244}
245
246#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(deny_unknown_fields)]
248pub struct ProfileEnd {
249 pub note: String,
250}
251
252impl Validate for ProfileEnd {
253 fn validate(&self) -> Result<(), ValidationError> {
254 bounded(&self.note, "note", 4_000)
255 }
256}
257
258#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
260pub enum SessionTtl {
261 #[serde(rename = "15m")]
262 Minutes15,
263 #[serde(rename = "30m")]
264 Minutes30,
265 #[serde(rename = "45m")]
266 Minutes45,
267 #[serde(rename = "60m")]
268 Minutes60,
269 #[serde(rename = "120m")]
270 Minutes120,
271 #[serde(rename = "240m")]
272 Minutes240,
273}
274
275impl SessionTtl {
276 pub const ALL: [Self; 6] =
277 [Self::Minutes15, Self::Minutes30, Self::Minutes45, Self::Minutes60, Self::Minutes120, Self::Minutes240];
278
279 pub const fn minutes(self) -> u16 {
280 match self {
281 Self::Minutes15 => 15,
282 Self::Minutes30 => 30,
283 Self::Minutes45 => 45,
284 Self::Minutes60 => 60,
285 Self::Minutes120 => 120,
286 Self::Minutes240 => 240,
287 }
288 }
289
290 pub const fn seconds(self) -> i64 {
291 self.minutes() as i64 * 60
292 }
293}
294
295impl fmt::Display for SessionTtl {
296 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
297 write!(formatter, "{}m", self.minutes())
298 }
299}
300
301impl FromStr for SessionTtl {
302 type Err = ValidationError;
303
304 fn from_str(value: &str) -> Result<Self, Self::Err> {
305 match value {
306 "15m" => Ok(Self::Minutes15),
307 "30m" => Ok(Self::Minutes30),
308 "45m" => Ok(Self::Minutes45),
309 "60m" => Ok(Self::Minutes60),
310 "120m" => Ok(Self::Minutes120),
311 "240m" => Ok(Self::Minutes240),
312 _ => Err(ValidationError::Invalid {
313 field: "ttl",
314 reason: "expected 15m, 30m, 45m, 60m, 120m, or 240m".into(),
315 }),
316 }
317 }
318}
319
320#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(rename_all = "snake_case")]
322pub enum SessionStatus {
323 Active,
324 Ended,
325 Expired,
326}
327
328#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
329pub struct Session {
330 pub id: SessionId,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub profile_id: Option<ProfileId>,
333 pub incognito: bool,
334 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub location: Option<ProxyLocation>,
336 pub name: String,
337 pub description: String,
338 pub status: SessionStatus,
339 pub initiator_id: IdentityId,
340 #[serde(default, skip_serializing_if = "Vec::is_empty")]
341 pub participant_ids: Vec<IdentityId>,
342 pub ttl: SessionTtl,
343 pub started_at: DateTime<Utc>,
344 pub expires_at: DateTime<Utc>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub ended_at: Option<DateTime<Utc>>,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub end_note: Option<String>,
349 pub usage: UsageTotal,
350}
351
352impl Session {
353 pub fn is_participant(&self, identity_id: &str) -> bool {
354 same_principal(&self.initiator_id, identity_id)
355 || self.participant_ids.iter().any(|participant| same_principal(participant, identity_id))
356 }
357
358 pub fn ttl_left_seconds_at(&self, now: DateTime<Utc>) -> u64 {
359 if self.status == SessionStatus::Active {
360 self.expires_at.signed_duration_since(now).num_seconds().max(0) as u64
361 } else {
362 0
363 }
364 }
365}
366
367#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
368#[serde(deny_unknown_fields)]
369pub struct SessionCreate {
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub profile_id: Option<ProfileId>,
372 #[serde(default)]
373 pub incognito: bool,
374 pub name: String,
375 pub description: String,
376 pub ttl: SessionTtl,
377}
378
379impl SessionCreate {
380 pub fn with_profile(
381 profile_id: impl Into<ProfileId>,
382 name: impl Into<String>,
383 description: impl Into<String>,
384 ttl: SessionTtl,
385 ) -> Self {
386 Self {
387 profile_id: Some(profile_id.into()),
388 incognito: false,
389 name: name.into(),
390 description: description.into(),
391 ttl,
392 }
393 }
394
395 pub fn incognito(name: impl Into<String>, description: impl Into<String>, ttl: SessionTtl) -> Self {
396 Self { profile_id: None, incognito: true, name: name.into(), description: description.into(), ttl }
397 }
398}
399
400impl Validate for SessionCreate {
401 fn validate(&self) -> Result<(), ValidationError> {
402 match (&self.profile_id, self.incognito) {
403 (Some(_), true) => {
404 return Err(ValidationError::Conflict { left: "profile_id", right: "incognito" });
405 }
406 (None, false) => {
407 return Err(ValidationError::Required { field: "profile_id or incognito" });
408 }
409 (Some(profile_id), false) => identifier(profile_id, "profile_id")?,
410 (None, true) => {}
411 }
412 bounded(&self.name, "name", 120)?;
413 bounded(&self.description, "description", 2_000)
414 }
415}
416
417#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
418#[serde(deny_unknown_fields)]
419pub struct SessionEnd {
420 pub note: String,
421}
422
423impl Validate for SessionEnd {
424 fn validate(&self) -> Result<(), ValidationError> {
425 bounded(&self.note, "note", 4_000)
426 }
427}
428
429#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
430pub struct SessionLog {
431 pub sequence: u64,
432 pub at: DateTime<Utc>,
433 pub actor_id: IdentityId,
434 pub command: String,
435 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub exit_code: Option<i32>,
437}
438
439#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
440pub struct SessionLogs {
441 pub session_id: SessionId,
442 pub date: NaiveDate,
443 pub entries: Vec<SessionLog>,
444}
445
446#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
447pub struct LiveLink {
448 pub session_id: SessionId,
449 pub url: String,
450 pub expires_at: DateTime<Utc>,
451}
452
453impl Validate for LiveLink {
454 fn validate(&self) -> Result<(), ValidationError> {
455 identifier(&self.session_id, "session_id")?;
456 validate_http_url(&self.url, "url")
457 }
458}
459
460#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
465#[serde(deny_unknown_fields)]
466pub struct LiveRedeemRequest {
467 pub grant: String,
468}
469
470impl Validate for LiveRedeemRequest {
471 fn validate(&self) -> Result<(), ValidationError> {
472 bounded(&self.grant, "grant", 64 * 1024)?;
473 if self.grant.chars().any(|character| character.is_whitespace() || character.is_control()) {
474 return Err(ValidationError::Invalid {
475 field: "grant",
476 reason: "expected one opaque value without whitespace or controls".into(),
477 });
478 }
479 Ok(())
480 }
481}
482
483#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
484#[serde(rename_all = "snake_case")]
485pub enum RecordingStatus {
486 Pending,
487 Available,
488 Trashed,
489 Failed,
490}
491
492#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
493pub struct Recording {
494 pub session_id: SessionId,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
496 pub profile_id: Option<ProfileId>,
497 pub incognito: bool,
498 pub session_name: String,
499 pub session_description: String,
500 pub owner_id: IdentityId,
501 #[serde(default, skip_serializing_if = "Vec::is_empty")]
503 pub participant_ids: Vec<IdentityId>,
504 pub briefcase_path: String,
506 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub briefcase_link: Option<String>,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
509 pub command_log_link: Option<String>,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub command_log_path: Option<String>,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub delivery_error: Option<String>,
515 pub duration_seconds: u64,
516 pub size_bytes: u64,
517 pub status: RecordingStatus,
518 pub created_at: DateTime<Utc>,
519 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub trashed_at: Option<DateTime<Utc>>,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub purge_at: Option<DateTime<Utc>>,
523}
524
525impl Recording {
526 pub fn private_path(owner_id: &str, session_id: &str) -> Result<String, ValidationError> {
529 identifier(owner_id, "owner_id")?;
530 identifier(session_id, "session_id")?;
531 Ok(format!("private/{owner_id}/sb/{session_id}"))
532 }
533
534 pub fn is_participant(&self, identity_id: &str) -> bool {
535 same_principal(&self.owner_id, identity_id)
536 || self.participant_ids.iter().any(|participant| same_principal(participant, identity_id))
537 }
538}
539
540#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
541pub struct Money {
542 pub currency: String,
544 pub micros: u64,
546}
547
548#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
549pub struct UsageCost {
550 pub browser: Money,
551 pub proxy_in: Money,
552 pub proxy_out: Money,
553 #[serde(default)]
555 pub proxy_unclassified: Money,
556 pub total: Money,
557}
558
559#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
562pub struct UsageLimits {
563 pub concurrent_browser_limit: u64,
564 #[serde(default)]
566 pub rate_limit: Option<u64>,
567 pub checked_at: DateTime<Utc>,
569}
570
571#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
572pub struct UsageTotal {
573 pub sessions: u64,
574 pub browser_seconds: u64,
575 pub proxy_bytes_in: u64,
576 pub proxy_bytes_out: u64,
577 #[serde(default)]
579 pub proxy_bytes_unclassified: u64,
580 pub cost: UsageCost,
581}
582
583impl UsageTotal {
584 pub fn browser_minutes(&self) -> f64 {
585 self.browser_seconds as f64 / 60.0
586 }
587
588 pub fn proxy_gb_in(&self) -> f64 {
589 self.proxy_bytes_in as f64 / 1_000_000_000.0
590 }
591
592 pub fn proxy_gb_out(&self) -> f64 {
593 self.proxy_bytes_out as f64 / 1_000_000_000.0
594 }
595
596 pub fn proxy_gb_unclassified(&self) -> f64 {
597 self.proxy_bytes_unclassified as f64 / 1_000_000_000.0
598 }
599
600 pub fn proxy_gb_total(&self) -> f64 {
601 (self.proxy_bytes_in as f64 + self.proxy_bytes_out as f64 + self.proxy_bytes_unclassified as f64)
602 / 1_000_000_000.0
603 }
604}
605
606#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
607pub struct Usage {
608 pub session_id: SessionId,
609 pub started_at: DateTime<Utc>,
610 #[serde(default, skip_serializing_if = "Vec::is_empty")]
611 pub principal_ids: Vec<IdentityId>,
612 pub browser_seconds: u64,
613 pub proxy_bytes_in: u64,
614 pub proxy_bytes_out: u64,
615 #[serde(default)]
617 pub proxy_bytes_unclassified: u64,
618 pub cost: UsageCost,
619}
620
621impl Usage {
622 pub fn is_for(&self, identity_id: &str) -> bool {
623 self.principal_ids.iter().any(|principal| same_principal(principal, identity_id))
624 }
625}
626
627#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
628#[serde(rename_all = "snake_case")]
629pub enum SearchType {
630 #[default]
631 Web,
632 News,
633 Research,
634}
635
636#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
637#[serde(deny_unknown_fields)]
638pub struct SearchRequest {
639 pub query: String,
640 pub purpose: String,
641 #[serde(default, rename = "type")]
642 pub search_type: SearchType,
643 #[serde(default, skip_serializing_if = "Vec::is_empty")]
644 pub include_domains: Vec<String>,
645 #[serde(default, skip_serializing_if = "Vec::is_empty")]
646 pub exclude_domains: Vec<String>,
647 #[serde(default, skip_serializing_if = "Option::is_none")]
648 pub location: Option<String>,
649 #[serde(default, skip_serializing_if = "Option::is_none")]
650 pub language: Option<String>,
651 #[serde(default, skip_serializing_if = "Option::is_none")]
652 pub recency_minutes: Option<u64>,
653 #[serde(default, skip_serializing_if = "Option::is_none")]
654 pub after: Option<NaiveDate>,
655 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub before: Option<NaiveDate>,
657 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub pub_year_min: Option<i32>,
659 #[serde(default, skip_serializing_if = "Option::is_none")]
660 pub pub_year_max: Option<i32>,
661 #[serde(default)]
662 pub page: u8,
663}
664
665impl Validate for SearchRequest {
666 fn validate(&self) -> Result<(), ValidationError> {
667 bounded(&self.query, "query", 16_384)?;
668 purpose(&self.purpose)?;
669 collection_len(self.include_domains.len(), "include_domains", 100)?;
670 collection_len(self.exclude_domains.len(), "exclude_domains", 100)?;
671 if self.page > 10 {
672 return Err(ValidationError::OutOfRange { field: "page", min: 0, max: 10 });
673 }
674 if self.recency_minutes == Some(0) {
675 return Err(ValidationError::OutOfRange { field: "recency_minutes", min: 1, max: u64::MAX });
676 }
677 if self.recency_minutes.is_some() && (self.after.is_some() || self.before.is_some()) {
678 return Err(ValidationError::Conflict { left: "recency_minutes", right: "after/before" });
679 }
680 if self.after.zip(self.before).is_some_and(|(after, before)| after > before) {
681 return Err(ValidationError::Invalid { field: "after", reason: "must not be later than before".into() });
682 }
683 if (self.pub_year_min.is_some() || self.pub_year_max.is_some()) && self.search_type != SearchType::Research {
684 return Err(ValidationError::Invalid {
685 field: "pub_year_min/pub_year_max",
686 reason: "publication years are available only for research search".into(),
687 });
688 }
689 if self.pub_year_min.zip(self.pub_year_max).is_some_and(|(min, max)| min > max) {
690 return Err(ValidationError::Invalid {
691 field: "pub_year_min",
692 reason: "must not exceed pub_year_max".into(),
693 });
694 }
695 for domain in &self.include_domains {
696 validate_domain(domain, "include_domains")?;
697 }
698 for domain in &self.exclude_domains {
699 validate_domain(domain, "exclude_domains")?;
700 }
701 for (field, value) in [("location", self.location.as_deref()), ("language", self.language.as_deref())] {
702 if let Some(value) = value {
703 bounded(value, field, 256)?;
704 }
705 }
706 Ok(())
707 }
708}
709
710#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
711pub struct SearchResult {
712 pub rank: u32,
713 pub title: String,
714 pub url: String,
715 #[serde(default, skip_serializing_if = "Option::is_none")]
716 pub snippet: Option<String>,
717 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub published_at: Option<DateTime<Utc>>,
719}
720
721#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
722pub struct SearchResponse {
723 pub results: Vec<SearchResult>,
724 pub page: u8,
725 #[serde(default)]
726 pub queued_ms: u64,
727}
728
729#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
730#[serde(rename_all = "snake_case")]
731pub enum FetchFormat {
732 #[default]
733 Markdown,
734 Html,
735 Json,
736}
737
738#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
739#[serde(deny_unknown_fields)]
740pub struct FetchRequest {
741 pub urls: Vec<String>,
742 pub purpose: String,
743 #[serde(default)]
744 pub format: FetchFormat,
745 #[serde(default)]
746 pub links: bool,
747 #[serde(default)]
748 pub image_links: bool,
749 #[serde(default, skip_serializing_if = "Option::is_none")]
750 pub ttl_seconds: Option<u64>,
751 #[serde(default, skip_serializing_if = "Option::is_none")]
752 pub timeout_ms: Option<u64>,
753 #[serde(default, skip_serializing_if = "Vec::is_empty")]
754 pub include_selectors: Vec<String>,
755 #[serde(default, skip_serializing_if = "Vec::is_empty")]
756 pub exclude_selectors: Vec<String>,
757}
758
759impl FetchRequest {
760 pub const UPSTREAM_BATCH_SIZE: usize = 10;
761
762 pub fn batches(&self) -> impl Iterator<Item = &[String]> {
764 self.urls.chunks(Self::UPSTREAM_BATCH_SIZE)
765 }
766}
767
768impl Validate for FetchRequest {
769 fn validate(&self) -> Result<(), ValidationError> {
770 if self.urls.is_empty() {
771 return Err(ValidationError::Required { field: "urls" });
772 }
773 collection_len(self.urls.len(), "urls", 1_000)?;
774 purpose(&self.purpose)?;
775 for url in &self.urls {
776 validate_http_url(url, "urls")?;
777 }
778 if let Some(timeout) = self.timeout_ms
779 && !(1..=110_000).contains(&timeout)
780 {
781 return Err(ValidationError::OutOfRange { field: "timeout_ms", min: 1, max: 110_000 });
782 }
783 collection_len(
784 self.include_selectors.len().saturating_add(self.exclude_selectors.len()),
785 "include_selectors/exclude_selectors",
786 20,
787 )?;
788 validate_selectors(&self.include_selectors, "include_selectors")?;
789 validate_selectors(&self.exclude_selectors, "exclude_selectors")?;
790 Ok(())
791 }
792}
793
794#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
795#[serde(rename_all = "snake_case")]
796pub enum FetchStatus {
797 Ok,
798 Error,
799}
800
801#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
802pub struct FetchItem {
803 pub url: String,
804 pub status: FetchStatus,
805 #[serde(default, skip_serializing_if = "Option::is_none")]
806 pub content: Option<String>,
807 #[serde(default, skip_serializing_if = "Vec::is_empty")]
808 pub links: Vec<String>,
809 #[serde(default, skip_serializing_if = "Vec::is_empty")]
810 pub image_links: Vec<String>,
811 #[serde(default, skip_serializing_if = "Option::is_none")]
812 pub error: Option<ApiError>,
813 #[serde(default)]
814 pub cached: bool,
815}
816
817#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
818pub struct FetchResponse {
819 pub items: Vec<FetchItem>,
821 #[serde(default)]
822 pub queued_ms: u64,
823}
824
825#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
826#[serde(deny_unknown_fields)]
827pub struct RunRequest {
828 pub session_id: SessionId,
829 pub command: String,
831 #[serde(default, skip_serializing_if = "Vec::is_empty")]
832 pub flags: Vec<String>,
833}
834
835impl Validate for RunRequest {
836 fn validate(&self) -> Result<(), ValidationError> {
837 identifier(&self.session_id, "session_id")?;
838 bounded(&self.command, "command", 1_048_576)?;
839 if self.command.contains('\0') {
840 return Err(ValidationError::Invalid { field: "command", reason: "must not contain NUL".into() });
841 }
842 collection_len(self.flags.len(), "flags", 256)?;
843 for flag in &self.flags {
844 if flag.chars().count() > 16_384 {
845 return Err(ValidationError::TooLong { field: "flags", max: 16_384 });
846 }
847 if flag.contains('\0') {
848 return Err(ValidationError::Invalid { field: "flags", reason: "must not contain NUL".into() });
849 }
850 }
851 Ok(())
852 }
853}
854
855#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
856#[serde(tag = "type", rename_all = "snake_case")]
857pub enum RunEvent {
858 Queued {
859 position: u64,
860 },
861 Started {
862 at: DateTime<Utc>,
863 },
864 Stdout {
865 chunk: String,
866 },
867 Stderr {
868 chunk: String,
869 },
870 Warning {
871 message: String,
872 },
873 Failed {
876 error: ApiError,
877 },
878 Finished {
879 result: RunResult,
880 },
881}
882
883#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
884pub struct RunResult {
885 pub session_id: SessionId,
886 pub exit_code: i32,
887 pub started_at: DateTime<Utc>,
888 pub finished_at: DateTime<Utc>,
889 #[serde(default, skip_serializing_if = "String::is_empty")]
890 pub stdout: String,
891 #[serde(default, skip_serializing_if = "String::is_empty")]
892 pub stderr: String,
893}
894
895impl RunResult {
896 pub fn succeeded(&self) -> bool {
897 self.exit_code == 0
898 }
899}
900
901pub(crate) fn same_principal(left: &str, right: &str) -> bool {
902 left.trim().trim_start_matches('@') == right.trim().trim_start_matches('@')
903}
904
905fn validate_http_url(value: &str, field: &'static str) -> Result<(), ValidationError> {
906 if value.chars().count() > 16_384 {
907 return Err(ValidationError::TooLong { field, max: 16_384 });
908 }
909 let parsed = Url::parse(value).map_err(|error| ValidationError::Invalid { field, reason: error.to_string() })?;
910 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
911 return Err(ValidationError::Invalid { field, reason: "expected an absolute http(s) URL".into() });
912 }
913 Ok(())
914}
915
916fn validate_domain(value: &str, field: &'static str) -> Result<(), ValidationError> {
917 bounded(value, field, 253)?;
918 if value.chars().any(char::is_whitespace) || value.contains('/') || value.contains("://") {
919 return Err(ValidationError::Invalid { field, reason: "expected a hostname, not a URL".into() });
920 }
921 let candidate = format!("https://{}", value.trim_start_matches("*."));
922 let parsed =
923 Url::parse(&candidate).map_err(|error| ValidationError::Invalid { field, reason: error.to_string() })?;
924 if parsed.host_str().is_none() {
925 return Err(ValidationError::Invalid { field, reason: "expected a hostname".into() });
926 }
927 Ok(())
928}
929
930fn validate_selectors(selectors: &[String], field: &'static str) -> Result<(), ValidationError> {
931 collection_len(selectors.len(), field, 20)?;
932 for selector in selectors {
933 bounded(selector, field, 4_096)?;
934 }
935 Ok(())
936}
937
938#[cfg(test)]
939mod model_tests {
940 use chrono::TimeZone;
941 use serde::de::DeserializeOwned;
942 use serde_json::{Value, json};
943
944 use super::*;
945
946 fn search() -> SearchRequest {
947 SearchRequest {
948 query: "browser research".into(),
949 purpose: "Find primary sources".into(),
950 search_type: SearchType::Web,
951 include_domains: Vec::new(),
952 exclude_domains: Vec::new(),
953 location: None,
954 language: None,
955 recency_minutes: None,
956 after: None,
957 before: None,
958 pub_year_min: None,
959 pub_year_max: None,
960 page: 0,
961 }
962 }
963
964 fn fetch(urls: usize) -> FetchRequest {
965 FetchRequest {
966 urls: (0..urls).map(|index| format!("https://example.com/{index}")).collect(),
967 purpose: "Read the relevant sections".into(),
968 format: FetchFormat::Markdown,
969 links: false,
970 image_links: false,
971 ttl_seconds: None,
972 timeout_ms: None,
973 include_selectors: Vec::new(),
974 exclude_selectors: Vec::new(),
975 }
976 }
977
978 fn rejects_unknown_field<T: DeserializeOwned>(mut value: Value) {
979 value.as_object_mut().unwrap().insert("unexpected".into(), Value::Bool(true));
980 assert!(serde_json::from_value::<T>(value).is_err());
981 }
982
983 #[test]
985 fn request_payloads_reject_unknown_fields() {
986 rejects_unknown_field::<AuthExchangeRequest>(json!({"short_lived_token": "slt_value", "org_id": "tos"}));
987 rejects_unknown_field::<AuthRefreshRequest>(json!({"refresh_token": "ort_value", "org_id": "tos"}));
988 rejects_unknown_field::<ProfileCreate>(json!({"name": "primary", "location": "in", "access": []}));
989 rejects_unknown_field::<ProfileUpdate>(json!({"name": "renamed"}));
990 rejects_unknown_field::<ProfileEnd>(json!({"note": "done"}));
991 rejects_unknown_field::<SessionCreate>(json!({
992 "profile_id": "profile-1",
993 "name": "research",
994 "description": "primary sources",
995 "ttl": "30m"
996 }));
997 rejects_unknown_field::<SessionEnd>(json!({"note": "done"}));
998 rejects_unknown_field::<LiveRedeemRequest>(json!({"grant": "opaque-grant"}));
999 rejects_unknown_field::<SearchRequest>(json!({"query": "browsers", "purpose": "research"}));
1000 rejects_unknown_field::<FetchRequest>(json!({"urls": ["https://example.com"], "purpose": "read"}));
1001 rejects_unknown_field::<RunRequest>(json!({"session_id": "session-1", "command": "snapshot"}));
1002 }
1003
1004 #[test]
1006 fn live_redeem_grant_is_bounded_and_control_free() {
1007 assert!(LiveRedeemRequest { grant: "opaque-grant".into() }.validate().is_ok());
1008 assert!(LiveRedeemRequest { grant: String::new() }.validate().is_err());
1009 assert!(LiveRedeemRequest { grant: "grant\nheader".into() }.validate().is_err());
1010 assert!(LiveRedeemRequest { grant: "x".repeat(64 * 1024 + 1) }.validate().is_err());
1011 }
1012
1013 #[test]
1015 fn ttl_contract_is_exact() {
1016 let expected = ["15m", "30m", "45m", "60m", "120m", "240m"];
1017 for (ttl, expected) in SessionTtl::ALL.into_iter().zip(expected) {
1018 assert_eq!(ttl.to_string(), expected);
1019 assert_eq!(expected.parse::<SessionTtl>().unwrap(), ttl);
1020 assert_eq!(serde_json::to_string(&ttl).unwrap(), format!(r#""{expected}""#));
1021 }
1022 assert!("90m".parse::<SessionTtl>().is_err());
1023 }
1024
1025 #[test]
1027 fn session_mode_is_exclusive() {
1028 assert!(
1029 SessionCreate::with_profile("profile-1", "name", "description", SessionTtl::Minutes30).validate().is_ok()
1030 );
1031 assert!(SessionCreate::incognito("name", "description", SessionTtl::Minutes15).validate().is_ok());
1032
1033 let neither = SessionCreate {
1034 profile_id: None,
1035 incognito: false,
1036 name: "name".into(),
1037 description: "description".into(),
1038 ttl: SessionTtl::Minutes15,
1039 };
1040 assert!(neither.validate().is_err());
1041
1042 let both = SessionCreate { profile_id: Some("profile-1".into()), incognito: true, ..neither };
1043 assert!(both.validate().is_err());
1044 }
1045
1046 #[test]
1048 fn ttl_left_is_clamped_at_zero() {
1049 let expires = Utc.with_ymd_and_hms(2026, 8, 1, 12, 0, 0).unwrap();
1050 let mut session = Session {
1051 id: "session-1".into(),
1052 profile_id: None,
1053 incognito: true,
1054 location: None,
1055 name: "research".into(),
1056 description: "test".into(),
1057 status: SessionStatus::Expired,
1058 initiator_id: "silicon-1".into(),
1059 participant_ids: Vec::new(),
1060 ttl: SessionTtl::Minutes15,
1061 started_at: expires - chrono::Duration::minutes(15),
1062 expires_at: expires,
1063 ended_at: Some(expires),
1064 end_note: Some("ttl reached".into()),
1065 usage: UsageTotal::default(),
1066 };
1067 assert_eq!(session.ttl_left_seconds_at(expires + chrono::Duration::seconds(3)), 0);
1068 let before_expiry = expires - chrono::Duration::minutes(5);
1069 session.status = SessionStatus::Active;
1070 assert_eq!(session.ttl_left_seconds_at(before_expiry), 300);
1071 assert_eq!(session.ttl_left_seconds_at(expires + chrono::Duration::seconds(3)), 0);
1072 session.status = SessionStatus::Ended;
1073 session.ended_at = Some(before_expiry);
1074 assert_eq!(session.ttl_left_seconds_at(before_expiry), 0);
1075 }
1076
1077 #[test]
1079 fn profile_update_requires_a_mutable_field() {
1080 assert!(ProfileUpdate::default().validate().is_err());
1081 assert!(ProfileUpdate { name: Some("new name".into()), access: None }.validate().is_ok());
1082 }
1083
1084 #[test]
1086 fn auth_tokens_are_redacted_and_typed() {
1087 let exchange = AuthExchangeRequest { short_lived_token: "oac_single_use".into(), org_id: "tos".into() };
1088 assert!(exchange.validate().is_ok());
1089 assert_eq!(
1090 serde_json::to_value(&exchange).unwrap(),
1091 json!({"short_lived_token": "oac_single_use", "org_id": "tos"})
1092 );
1093 assert!(!format!("{exchange:?}").contains("oac_single_use"));
1094 assert!(
1095 AuthExchangeRequest { short_lived_token: "oac_single_use".into(), org_id: String::new() }
1096 .validate()
1097 .is_err()
1098 );
1099 assert!(
1100 AuthExchangeRequest { short_lived_token: "oat_wrong_family".into(), org_id: "tos".into() }
1101 .validate()
1102 .is_err()
1103 );
1104 assert!(
1105 serde_json::from_value::<AuthExchangeRequest>(json!({
1106 "short_lived_token": "oac_single_use"
1107 }))
1108 .is_err()
1109 );
1110
1111 let request = AuthRefreshRequest { refresh_token: "ort_secret".into(), org_id: "tos".into() };
1112 assert!(request.validate().is_ok());
1113 assert!(!format!("{request:?}").contains("ort_secret"));
1114
1115 let bad = AuthRefreshRequest { refresh_token: "oat_wrong-family".into(), org_id: "tos".into() };
1116 assert!(bad.validate().is_err());
1117
1118 let mut session = AuthSession {
1119 access_token: "oat_access".into(),
1120 refresh_token: "ort_refresh".into(),
1121 expires_at: Utc.timestamp_opt(1_800_000_000, 0).unwrap(),
1122 identity: Identity {
1123 id: "silicon-1".into(),
1124 name: "Silicon".into(),
1125 kind: IdentityKind::Silicon,
1126 tags: vec![],
1127 verified_aliases: Vec::new(),
1128 },
1129 org: Org { id: "tos".into(), name: "TOS".into() },
1130 services: vec!["session".into()],
1131 };
1132 assert!(session.validate().is_ok());
1133 session.access_token = "oat_bad\nheader".into();
1134 assert!(session.validate().is_err());
1135 }
1136
1137 #[test]
1139 fn search_constraints_match_the_cli_contract() {
1140 let mut request = search();
1141 request.page = 11;
1142 assert!(request.validate().is_err());
1143
1144 let mut request = search();
1145 request.recency_minutes = Some(60);
1146 request.after = NaiveDate::from_ymd_opt(2026, 8, 1);
1147 assert!(request.validate().is_err());
1148
1149 let mut request = search();
1150 request.pub_year_min = Some(2020);
1151 assert!(request.validate().is_err());
1152 request.search_type = SearchType::Research;
1153 assert!(request.validate().is_ok());
1154
1155 let mut request = search();
1156 request.purpose = "x".repeat(2_001);
1157 assert!(request.validate().is_err());
1158 }
1159
1160 #[test]
1162 fn search_text_and_domain_bounds_are_enforced() {
1163 let mut request = search();
1164 request.query = "q".repeat(16_385);
1165 assert_eq!(request.validate(), Err(ValidationError::TooLong { field: "query", max: 16_384 }));
1166
1167 let mut request = search();
1168 request.include_domains = vec!["example.com".into(); 101];
1169 assert_eq!(request.validate(), Err(ValidationError::TooMany { field: "include_domains", max: 100 }));
1170
1171 let mut request = search();
1172 request.exclude_domains = vec!["x".repeat(254)];
1173 assert_eq!(request.validate(), Err(ValidationError::TooLong { field: "exclude_domains", max: 253 }));
1174 }
1175
1176 #[test]
1178 fn fetch_batches_boundaries_without_reordering() {
1179 let request = fetch(21);
1180 assert!(request.validate().is_ok());
1181 let sizes: Vec<_> = request.batches().map(<[String]>::len).collect();
1182 assert_eq!(sizes, [10, 10, 1]);
1183 assert_eq!(request.batches().nth(1).unwrap()[0], "https://example.com/10");
1184 }
1185
1186 #[test]
1188 fn fetch_validation_accepts_live_cache_bypass_but_rejects_unsafe_inputs() {
1189 let mut request = fetch(1);
1190 request.ttl_seconds = Some(0);
1191 request.timeout_ms = Some(110_000);
1192 assert!(request.validate().is_ok());
1193
1194 request.timeout_ms = Some(110_001);
1195 assert!(request.validate().is_err());
1196
1197 let mut request = fetch(1);
1198 request.urls[0] = "file:///etc/passwd".into();
1199 assert!(request.validate().is_err());
1200
1201 let mut request = fetch(1);
1202 request.include_selectors = vec!["main".into(); 21];
1203 assert!(request.validate().is_err());
1204
1205 let mut request = fetch(1);
1206 request.include_selectors = vec!["main".into(); 10];
1207 request.exclude_selectors = vec!["nav".into(); 11];
1208 assert_eq!(
1209 request.validate(),
1210 Err(ValidationError::TooMany { field: "include_selectors/exclude_selectors", max: 20 })
1211 );
1212 }
1213
1214 #[test]
1216 fn fetch_collection_and_string_bounds_are_enforced() {
1217 let request = fetch(1_001);
1218 assert_eq!(request.validate(), Err(ValidationError::TooMany { field: "urls", max: 1_000 }));
1219
1220 let mut request = fetch(1);
1221 request.urls[0] = format!("https://example.com/{}", "x".repeat(16_365));
1222 assert_eq!(request.validate(), Err(ValidationError::TooLong { field: "urls", max: 16_384 }));
1223
1224 let mut request = fetch(1);
1225 request.exclude_selectors = vec!["x".repeat(4_097)];
1226 assert_eq!(request.validate(), Err(ValidationError::TooLong { field: "exclude_selectors", max: 4_096 }));
1227 }
1228
1229 #[test]
1231 fn profile_and_session_text_bounds_are_enforced() {
1232 let profile = ProfileCreate { name: "x".repeat(101), location: "in".into(), access: AccessList::default() };
1233 assert_eq!(profile.validate(), Err(ValidationError::TooLong { field: "name", max: 100 }));
1234
1235 let profile = ProfileCreate { name: "name".into(), location: "1n".into(), access: AccessList::default() };
1236 assert!(profile.validate().is_err());
1237
1238 let session = SessionCreate::incognito("research", "x".repeat(2_001), SessionTtl::Minutes15);
1239 assert_eq!(session.validate(), Err(ValidationError::TooLong { field: "description", max: 2_000 }));
1240
1241 let end = SessionEnd { note: "x".repeat(4_001) };
1242 assert_eq!(end.validate(), Err(ValidationError::TooLong { field: "note", max: 4_000 }));
1243 }
1244
1245 #[test]
1247 fn recording_path_is_safe_and_deterministic() {
1248 assert_eq!(Recording::private_path("silicon-1", "session-1").unwrap(), "private/silicon-1/sb/session-1");
1249 assert!(Recording::private_path("../other", "session-1").is_err());
1250 }
1251
1252 #[test]
1254 fn recording_wire_exposes_source_session_metadata() {
1255 let recording: Recording = serde_json::from_value(json!({
1256 "session_id": "session-1",
1257 "profile_id": "profile-1",
1258 "incognito": false,
1259 "session_name": "Market scan",
1260 "session_description": "Research browser vendors",
1261 "owner_id": "silicon-1",
1262 "participant_ids": ["silicon-1", "carbon-1"],
1263 "briefcase_path": "private/silicon-1/sb/session-1/recording.mp4",
1264 "briefcase_link": null,
1265 "duration_seconds": 60,
1266 "size_bytes": 100,
1267 "status": "pending",
1268 "created_at": "2026-08-10T12:00:00Z"
1269 }))
1270 .unwrap();
1271 assert_eq!(recording.profile_id.as_deref(), Some("profile-1"));
1272 assert!(!recording.incognito);
1273 assert_eq!(recording.participant_ids, ["silicon-1", "carbon-1"]);
1274
1275 let value = serde_json::to_value(recording).unwrap();
1276 assert_eq!(value["profile_id"], "profile-1");
1277 assert_eq!(value["incognito"], false);
1278 assert_eq!(value["participant_ids"], json!(["silicon-1", "carbon-1"]));
1279 }
1280
1281 #[test]
1283 fn usage_units_are_derived_from_exact_counters() {
1284 let usage = UsageTotal {
1285 browser_seconds: 90,
1286 proxy_bytes_in: 1_500_000_000,
1287 proxy_bytes_out: 2_000_000_000,
1288 ..UsageTotal::default()
1289 };
1290 assert_eq!(usage.browser_minutes(), 1.5);
1291 assert_eq!(usage.proxy_gb_in(), 1.5);
1292 assert_eq!(usage.proxy_gb_out(), 2.0);
1293 }
1294
1295 #[test]
1297 fn run_request_validation_does_not_rewrite_commands() {
1298 let request = RunRequest {
1299 session_id: "session-1".into(),
1300 command: "evaluate 'a b' ".into(),
1301 flags: vec!["--json".into()],
1302 };
1303 let before = request.clone();
1304 request.validate().unwrap();
1305 assert_eq!(request, before);
1306 }
1307
1308 #[test]
1310 fn run_request_bounds_command_and_flags() {
1311 let mut request = RunRequest {
1312 session_id: "session-1".into(),
1313 command: "snapshot".into(),
1314 flags: vec!["--json".into(); 257],
1315 };
1316 assert_eq!(request.validate(), Err(ValidationError::TooMany { field: "flags", max: 256 }));
1317
1318 request.flags = vec!["x".repeat(16_385)];
1319 assert_eq!(request.validate(), Err(ValidationError::TooLong { field: "flags", max: 16_384 }));
1320
1321 request.flags = vec!["--header\0secret".into()];
1322 assert!(matches!(request.validate(), Err(ValidationError::Invalid { field: "flags", .. })));
1323
1324 request.flags.clear();
1325 request.command = "x".repeat(1_048_577);
1326 assert_eq!(request.validate(), Err(ValidationError::TooLong { field: "command", max: 1_048_576 }));
1327 }
1328}
1329
1330#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
1333pub struct SessionConnection {
1334 pub session_id: SessionId,
1335 pub principal_id: String,
1337 pub cdp_url: String,
1338 pub expires_at: DateTime<Utc>,
1339}
1340impl std::fmt::Debug for SessionConnection {
1341 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1342 f.debug_struct("SessionConnection")
1343 .field("session_id", &self.session_id)
1344 .field("cdp_url", &"[REDACTED]")
1345 .field("expires_at", &self.expires_at)
1346 .finish()
1347 }
1348}
1349
1350#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
1354#[serde(deny_unknown_fields)]
1355pub struct CommandReport {
1356 pub command_id: uuid::Uuid,
1357 pub command: String,
1358 #[serde(default)]
1359 pub flags: Vec<String>,
1360 pub started_at: DateTime<Utc>,
1361 pub finished_at: DateTime<Utc>,
1362 pub exit_code: i32,
1363 #[serde(default)]
1364 pub truncated: bool,
1365}
1366impl std::fmt::Debug for CommandReport {
1367 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1368 f.debug_struct("CommandReport").field("command_id", &self.command_id).field("content", &"[REDACTED]").finish()
1369 }
1370}
1371impl Validate for CommandReport {
1372 fn validate(&self) -> Result<(), ValidationError> {
1373 if self.command_id.is_nil() || self.finished_at < self.started_at {
1374 return Err(ValidationError::Invalid {
1375 field: "command_report",
1376 reason: "requires a nonzero ID and ordered timestamps".into(),
1377 });
1378 }
1379 RunRequest { session_id: "report".into(), command: self.command.clone(), flags: self.flags.clone() }
1380 .validate()?;
1381 if self.command.len() + self.flags.iter().map(String::len).sum::<usize>() > 1_048_576 {
1382 return Err(ValidationError::TooLong { field: "command", max: 1_048_576 });
1383 }
1384 Ok(())
1385 }
1386}
1387
1388#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1389pub struct CommandReportReceipt {
1390 pub command_id: uuid::Uuid,
1391 pub sequence: Option<u64>,
1393}