1use std::{fmt, net::Ipv4Addr, num::NonZeroU16, str::FromStr};
4
5use serde::{Deserialize, Deserializer, Serialize, de};
6use time::{OffsetDateTime, UtcOffset};
7use uuid::Uuid;
8
9pub const SOURCE_PATH: &str = "/api/admin/v1/source";
10pub const SOURCE_SYNCS_PATH: &str = "/api/admin/v1/source-syncs";
11
12const MIN_SOURCE_POLL_INTERVAL_SECONDS: u64 = 30;
13const MAX_SOURCE_POLL_INTERVAL_SECONDS: u64 = 24 * 60 * 60;
14pub const GIT_SHA1_SOURCE_COMMIT_PREFIX: &str = "git-sha1:";
15pub const GIT_SHA256_SOURCE_COMMIT_PREFIX: &str = "git-sha256:";
16const SOURCE_CONTENT_DIGEST_PREFIX: &str = "content-b3-v1-";
17
18const MAX_SSH_USER_BYTES: usize = 64;
19const MAX_SSH_HOST_BYTES: usize = 253;
20const MAX_REPOSITORY_PATH_BYTES: usize = 1_024;
21const MAX_BRANCH_BYTES: usize = 255;
22const MAX_CONTENT_SUBDIRECTORY_BYTES: usize = 1_024;
23const MAX_CREDENTIAL_NAME_BYTES: usize = 64;
24const MAX_SOURCE_VERSION: u64 = i64::MAX as u64;
25
26macro_rules! uuid_identifier {
27 ($name:ident) => {
28 #[derive(
29 Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
30 )]
31 #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
32 #[cfg_attr(feature = "schema", schema(value_type = Uuid, format = Uuid))]
33 #[serde(transparent)]
34 pub struct $name(Uuid);
35
36 impl $name {
37 pub const fn from_uuid(value: Uuid) -> Self {
38 Self(value)
39 }
40
41 pub const fn as_uuid(&self) -> &Uuid {
42 &self.0
43 }
44 }
45
46 impl fmt::Display for $name {
47 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48 self.0.fmt(formatter)
49 }
50 }
51
52 impl FromStr for $name {
53 type Err = uuid::Error;
54
55 fn from_str(value: &str) -> Result<Self, Self::Err> {
56 Uuid::parse_str(value).map(Self)
57 }
58 }
59 };
60}
61
62uuid_identifier!(SourceSyncId);
63
64macro_rules! source_string {
65 ($name:ident, $maximum:expr, $validator:expr, $message:literal) => {
66 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
67 #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
68 #[cfg_attr(feature = "schema", schema(value_type = String))]
69 pub struct $name(Box<str>);
70
71 impl $name {
72 pub fn parse(value: &str) -> Result<Self, SourceValueError> {
73 if value.is_empty() || value.len() > $maximum || !($validator)(value) {
74 return Err(SourceValueError($message));
75 }
76 Ok(Self(value.into()))
77 }
78
79 pub fn as_str(&self) -> &str {
80 &self.0
81 }
82 }
83
84 impl fmt::Display for $name {
85 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86 formatter.write_str(self.as_str())
87 }
88 }
89
90 impl FromStr for $name {
91 type Err = SourceValueError;
92
93 fn from_str(value: &str) -> Result<Self, Self::Err> {
94 Self::parse(value)
95 }
96 }
97
98 impl Serialize for $name {
99 fn serialize<SerializerType>(
100 &self,
101 serializer: SerializerType,
102 ) -> Result<SerializerType::Ok, SerializerType::Error>
103 where
104 SerializerType: serde::Serializer,
105 {
106 serializer.serialize_str(self.as_str())
107 }
108 }
109
110 impl<'de> Deserialize<'de> for $name {
111 fn deserialize<DeserializerType>(
112 deserializer: DeserializerType,
113 ) -> Result<Self, DeserializerType::Error>
114 where
115 DeserializerType: Deserializer<'de>,
116 {
117 let value = Box::<str>::deserialize(deserializer)?;
118 Self::parse(&value).map_err(de::Error::custom)
119 }
120 }
121 };
122}
123
124#[derive(Clone, Copy, Debug, Eq, PartialEq)]
125pub struct SourceValueError(&'static str);
126
127impl fmt::Display for SourceValueError {
128 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
129 formatter.write_str(self.0)
130 }
131}
132
133impl std::error::Error for SourceValueError {}
134
135source_string!(
136 SshRemoteUser,
137 MAX_SSH_USER_BYTES,
138 |value: &str| !value.starts_with('-')
139 && value
140 .bytes()
141 .all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') }),
142 "SSH user must contain only ASCII letters, digits, hyphens, underscores, and periods"
143);
144
145source_string!(
146 SshRepositoryPath,
147 MAX_REPOSITORY_PATH_BYTES,
148 valid_repository_path,
149 "SSH repository path must be a canonical path without credentials, traversal, or controls"
150);
151
152source_string!(
153 GitBranchName,
154 MAX_BRANCH_BYTES,
155 valid_branch,
156 "Git branch must be one exact canonical branch name"
157);
158
159source_string!(
160 RepositoryContentSubdirectory,
161 MAX_CONTENT_SUBDIRECTORY_BYTES,
162 valid_content_subdirectory,
163 "repository content subdirectory must be a portable relative path or a single period"
164);
165
166source_string!(
167 SshCredentialName,
168 MAX_CREDENTIAL_NAME_BYTES,
169 |value: &str| {
170 value.bytes().enumerate().all(|(index, byte)| {
171 byte.is_ascii_lowercase()
172 || byte.is_ascii_digit()
173 || (index > 0 && matches!(byte, b'-' | b'_'))
174 })
175 },
176 "SSH credential name must start with a lowercase letter or digit and use lowercase ASCII"
177);
178
179#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
182#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
183#[cfg_attr(feature = "schema", schema(value_type = String))]
184pub struct SshRemoteHost(Box<str>);
185
186impl SshRemoteHost {
187 pub fn parse(value: &str) -> Result<Self, SourceValueError> {
188 if value.is_empty() || value.len() > MAX_SSH_HOST_BYTES || !valid_host(value) {
189 return Err(SourceValueError(
190 "SSH host must be one canonical IPv4 address or unambiguous lowercase DNS name",
191 ));
192 }
193 Ok(Self(value.into()))
194 }
195
196 pub fn as_str(&self) -> &str {
197 &self.0
198 }
199}
200
201impl fmt::Display for SshRemoteHost {
202 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
203 formatter.write_str(self.as_str())
204 }
205}
206
207impl FromStr for SshRemoteHost {
208 type Err = SourceValueError;
209
210 fn from_str(value: &str) -> Result<Self, Self::Err> {
211 Self::parse(value)
212 }
213}
214
215impl Serialize for SshRemoteHost {
216 fn serialize<SerializerType>(
217 &self,
218 serializer: SerializerType,
219 ) -> Result<SerializerType::Ok, SerializerType::Error>
220 where
221 SerializerType: serde::Serializer,
222 {
223 serializer.serialize_str(self.as_str())
224 }
225}
226
227impl<'de> Deserialize<'de> for SshRemoteHost {
228 fn deserialize<DeserializerType>(
229 deserializer: DeserializerType,
230 ) -> Result<Self, DeserializerType::Error>
231 where
232 DeserializerType: Deserializer<'de>,
233 {
234 let value = Box::<str>::deserialize(deserializer)?;
235 Self::parse(&value).map_err(de::Error::custom)
236 }
237}
238
239#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
241#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
242#[cfg_attr(feature = "schema", schema(value_type = u16))]
243#[serde(transparent)]
244pub struct SshRemotePort(NonZeroU16);
245
246impl SshRemotePort {
247 pub const fn new(value: u16) -> Option<Self> {
248 match NonZeroU16::new(value) {
249 Some(value) => Some(Self(value)),
250 None => None,
251 }
252 }
253
254 pub const fn get(self) -> u16 {
255 self.0.get()
256 }
257}
258
259impl<'de> Deserialize<'de> for SshRemotePort {
260 fn deserialize<DeserializerType>(
261 deserializer: DeserializerType,
262 ) -> Result<Self, DeserializerType::Error>
263 where
264 DeserializerType: Deserializer<'de>,
265 {
266 let value = u16::deserialize(deserializer)?;
267 Self::new(value).ok_or_else(|| de::Error::custom("SSH port must be nonzero"))
268 }
269}
270
271#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
273#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
274#[cfg_attr(feature = "schema", schema(value_type = u64))]
275#[serde(transparent)]
276pub struct SourceConfigurationVersion(u64);
277
278impl SourceConfigurationVersion {
279 pub const fn new(value: u64) -> Option<Self> {
280 if value > 0 && value <= MAX_SOURCE_VERSION {
281 Some(Self(value))
282 } else {
283 None
284 }
285 }
286
287 pub const fn get(self) -> u64 {
288 self.0
289 }
290}
291
292impl<'de> Deserialize<'de> for SourceConfigurationVersion {
293 fn deserialize<DeserializerType>(
294 deserializer: DeserializerType,
295 ) -> Result<Self, DeserializerType::Error>
296 where
297 DeserializerType: Deserializer<'de>,
298 {
299 let value = u64::deserialize(deserializer)?;
300 Self::new(value)
301 .ok_or_else(|| de::Error::custom("source configuration version must be positive"))
302 }
303}
304
305#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
307#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
308#[cfg_attr(feature = "schema", schema(value_type = u64))]
309#[serde(transparent)]
310pub struct SourcePollInterval(u64);
311
312impl SourcePollInterval {
313 pub const fn from_seconds(value: u64) -> Option<Self> {
314 if value >= MIN_SOURCE_POLL_INTERVAL_SECONDS && value <= MAX_SOURCE_POLL_INTERVAL_SECONDS {
315 Some(Self(value))
316 } else {
317 None
318 }
319 }
320
321 pub const fn seconds(self) -> u64 {
322 self.0
323 }
324}
325
326impl<'de> Deserialize<'de> for SourcePollInterval {
327 fn deserialize<DeserializerType>(
328 deserializer: DeserializerType,
329 ) -> Result<Self, DeserializerType::Error>
330 where
331 DeserializerType: Deserializer<'de>,
332 {
333 let value = u64::deserialize(deserializer)?;
334 Self::from_seconds(value).ok_or_else(|| {
335 de::Error::custom(format_args!(
336 "source poll interval must be between {MIN_SOURCE_POLL_INTERVAL_SECONDS} and {MAX_SOURCE_POLL_INTERVAL_SECONDS} seconds"
337 ))
338 })
339 }
340}
341
342#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
343#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
344#[serde(deny_unknown_fields)]
345pub struct SshRemote {
346 pub user: SshRemoteUser,
347 pub host: SshRemoteHost,
348 pub port: SshRemotePort,
349 pub repository_path: SshRepositoryPath,
350}
351
352#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
353#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
354pub struct ManagedSourceConfiguration {
355 pub remote: SshRemote,
356 pub branch: GitBranchName,
357 pub content_subdirectory: RepositoryContentSubdirectory,
358 pub credential_name: SshCredentialName,
359 pub poll_interval_seconds: SourcePollInterval,
360 pub version: SourceConfigurationVersion,
361 #[serde(
362 serialize_with = "time::serde::rfc3339::serialize",
363 deserialize_with = "deserialize_utc_timestamp"
364 )]
365 #[cfg_attr(feature = "schema", schema(value_type = String, format = DateTime))]
366 pub updated_at: OffsetDateTime,
367}
368
369#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
370#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
371#[serde(rename_all = "snake_case")]
372pub enum SourceSyncRequestOrigin {
373 Startup,
374 Poll,
375 Manual,
376}
377
378impl SourceSyncRequestOrigin {
379 pub const fn as_str(self) -> &'static str {
380 match self {
381 Self::Startup => "startup",
382 Self::Poll => "poll",
383 Self::Manual => "manual",
384 }
385 }
386
387 pub fn parse(value: &str) -> Option<Self> {
388 match value {
389 "startup" => Some(Self::Startup),
390 "poll" => Some(Self::Poll),
391 "manual" => Some(Self::Manual),
392 _ => None,
393 }
394 }
395}
396
397#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
398#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
399#[serde(rename_all = "snake_case")]
400pub enum SourceSyncStage {
401 Queued,
402 Fetching,
403 ResolvingCommit,
404 PreparingCandidate,
405 Compiling,
406 Reloading,
407}
408
409impl SourceSyncStage {
410 pub const fn as_str(self) -> &'static str {
411 match self {
412 Self::Queued => "queued",
413 Self::Fetching => "fetching",
414 Self::ResolvingCommit => "resolving_commit",
415 Self::PreparingCandidate => "preparing_candidate",
416 Self::Compiling => "compiling",
417 Self::Reloading => "reloading",
418 }
419 }
420
421 pub fn parse(value: &str) -> Option<Self> {
422 match value {
423 "queued" => Some(Self::Queued),
424 "fetching" => Some(Self::Fetching),
425 "resolving_commit" => Some(Self::ResolvingCommit),
426 "preparing_candidate" => Some(Self::PreparingCandidate),
427 "compiling" => Some(Self::Compiling),
428 "reloading" => Some(Self::Reloading),
429 _ => None,
430 }
431 }
432}
433
434#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
435#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
436#[serde(rename_all = "snake_case")]
437pub enum SourceSyncOutcome {
438 Applied,
439 NoChange,
440 Failed,
441 Cancelled,
442}
443
444impl SourceSyncOutcome {
445 pub const fn as_str(self) -> &'static str {
446 match self {
447 Self::Applied => "applied",
448 Self::NoChange => "no_change",
449 Self::Failed => "failed",
450 Self::Cancelled => "cancelled",
451 }
452 }
453
454 pub fn parse(value: &str) -> Option<Self> {
455 match value {
456 "applied" => Some(Self::Applied),
457 "no_change" => Some(Self::NoChange),
458 "failed" => Some(Self::Failed),
459 "cancelled" => Some(Self::Cancelled),
460 _ => None,
461 }
462 }
463}
464
465#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
466#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
467#[serde(rename_all = "snake_case")]
468pub enum SourceSyncFailureCode {
469 ConfigurationChanged,
470 CredentialUnavailable,
471 UnknownHost,
472 AuthenticationFailed,
473 RemoteUnavailable,
474 BranchUnavailable,
475 FetchFailed,
476 CommitInvalid,
477 CandidateFailed,
478 ValidationFailed,
479 CompileFailed,
480 ReloadFailed,
481 TimedOut,
482 Interrupted,
483 Internal,
484}
485
486impl SourceSyncFailureCode {
487 pub const fn as_str(self) -> &'static str {
488 match self {
489 Self::ConfigurationChanged => "configuration_changed",
490 Self::CredentialUnavailable => "credential_unavailable",
491 Self::UnknownHost => "unknown_host",
492 Self::AuthenticationFailed => "authentication_failed",
493 Self::RemoteUnavailable => "remote_unavailable",
494 Self::BranchUnavailable => "branch_unavailable",
495 Self::FetchFailed => "fetch_failed",
496 Self::CommitInvalid => "commit_invalid",
497 Self::CandidateFailed => "candidate_failed",
498 Self::ValidationFailed => "validation_failed",
499 Self::CompileFailed => "compile_failed",
500 Self::ReloadFailed => "reload_failed",
501 Self::TimedOut => "timed_out",
502 Self::Interrupted => "interrupted",
503 Self::Internal => "internal",
504 }
505 }
506
507 pub fn parse(value: &str) -> Option<Self> {
508 match value {
509 "configuration_changed" => Some(Self::ConfigurationChanged),
510 "credential_unavailable" => Some(Self::CredentialUnavailable),
511 "unknown_host" => Some(Self::UnknownHost),
512 "authentication_failed" => Some(Self::AuthenticationFailed),
513 "remote_unavailable" => Some(Self::RemoteUnavailable),
514 "branch_unavailable" => Some(Self::BranchUnavailable),
515 "fetch_failed" => Some(Self::FetchFailed),
516 "commit_invalid" => Some(Self::CommitInvalid),
517 "candidate_failed" => Some(Self::CandidateFailed),
518 "validation_failed" => Some(Self::ValidationFailed),
519 "compile_failed" => Some(Self::CompileFailed),
520 "reload_failed" => Some(Self::ReloadFailed),
521 "timed_out" => Some(Self::TimedOut),
522 "interrupted" => Some(Self::Interrupted),
523 "internal" => Some(Self::Internal),
524 _ => None,
525 }
526 }
527}
528
529#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
530#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
531#[serde(rename_all = "snake_case")]
532pub enum SourceSyncAdmission {
533 Created,
534 Coalesced,
535 Replayed,
536}
537
538#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
539#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
540pub struct SourceSyncResource {
541 pub source_sync_id: SourceSyncId,
542 pub configuration_version: SourceConfigurationVersion,
543 pub request_origin: SourceSyncRequestOrigin,
544 pub stage: SourceSyncStage,
545 pub outcome: Option<SourceSyncOutcome>,
546 pub source_commit: Option<Box<str>>,
547 pub content_digest: Option<Box<str>>,
548 pub failure_code: Option<SourceSyncFailureCode>,
549 pub version: u64,
550 #[serde(serialize_with = "time::serde::rfc3339::serialize")]
551 #[cfg_attr(feature = "schema", schema(value_type = String, format = DateTime))]
552 pub requested_at: OffsetDateTime,
553 #[serde(serialize_with = "time::serde::rfc3339::serialize")]
554 #[cfg_attr(feature = "schema", schema(value_type = String, format = DateTime))]
555 pub updated_at: OffsetDateTime,
556 #[serde(serialize_with = "time::serde::rfc3339::option::serialize")]
557 #[cfg_attr(feature = "schema", schema(value_type = Option<String>, format = DateTime))]
558 pub finished_at: Option<OffsetDateTime>,
559}
560
561#[derive(Deserialize)]
562struct SourceSyncWire {
563 source_sync_id: SourceSyncId,
564 configuration_version: SourceConfigurationVersion,
565 request_origin: SourceSyncRequestOrigin,
566 stage: SourceSyncStage,
567 outcome: Option<SourceSyncOutcome>,
568 #[serde(default, deserialize_with = "deserialize_optional_source_commit")]
569 source_commit: Option<Box<str>>,
570 #[serde(
571 default,
572 deserialize_with = "deserialize_optional_source_content_digest"
573 )]
574 content_digest: Option<Box<str>>,
575 failure_code: Option<SourceSyncFailureCode>,
576 version: u64,
577 #[serde(deserialize_with = "deserialize_utc_timestamp")]
578 requested_at: OffsetDateTime,
579 #[serde(deserialize_with = "deserialize_utc_timestamp")]
580 updated_at: OffsetDateTime,
581 #[serde(default, deserialize_with = "deserialize_optional_utc_timestamp")]
582 finished_at: Option<OffsetDateTime>,
583}
584
585impl<'de> Deserialize<'de> for SourceSyncResource {
586 fn deserialize<DeserializerType>(
587 deserializer: DeserializerType,
588 ) -> Result<Self, DeserializerType::Error>
589 where
590 DeserializerType: Deserializer<'de>,
591 {
592 let wire = SourceSyncWire::deserialize(deserializer)?;
593 let resource = Self {
594 source_sync_id: wire.source_sync_id,
595 configuration_version: wire.configuration_version,
596 request_origin: wire.request_origin,
597 stage: wire.stage,
598 outcome: wire.outcome,
599 source_commit: wire.source_commit,
600 content_digest: wire.content_digest,
601 failure_code: wire.failure_code,
602 version: wire.version,
603 requested_at: wire.requested_at,
604 updated_at: wire.updated_at,
605 finished_at: wire.finished_at,
606 };
607 validate_source_sync_resource(&resource).map_err(de::Error::custom)?;
608 Ok(resource)
609 }
610}
611
612#[derive(Clone, Copy, Debug, Eq, PartialEq)]
613struct SourceWireDecodeError(&'static str);
614
615impl fmt::Display for SourceWireDecodeError {
616 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
617 formatter.write_str(self.0)
618 }
619}
620
621impl std::error::Error for SourceWireDecodeError {}
622
623fn validate_source_sync_resource(
624 resource: &SourceSyncResource,
625) -> Result<(), SourceWireDecodeError> {
626 if resource.version == 0 || resource.version > MAX_SOURCE_VERSION {
627 return Err(SourceWireDecodeError(
628 "source synchronization version must be a positive SQLite integer",
629 ));
630 }
631 if resource.updated_at < resource.requested_at
632 || resource
633 .finished_at
634 .is_some_and(|finished_at| finished_at < resource.updated_at)
635 {
636 return Err(SourceWireDecodeError(
637 "source synchronization timestamps must be monotonic",
638 ));
639 }
640 validate_source_sync_shape(resource)
641}
642
643fn validate_source_sync_shape(resource: &SourceSyncResource) -> Result<(), SourceWireDecodeError> {
644 if resource.outcome.is_some() != resource.finished_at.is_some()
645 || (resource.outcome == Some(SourceSyncOutcome::Failed)) != resource.failure_code.is_some()
646 {
647 return Err(SourceWireDecodeError(
648 "source synchronization terminal metadata is inconsistent",
649 ));
650 }
651 match resource.outcome {
652 Some(SourceSyncOutcome::Applied) => {
653 if resource.stage != SourceSyncStage::Reloading
654 || resource.source_commit.is_none()
655 || resource.content_digest.is_none()
656 {
657 return Err(SourceWireDecodeError(
658 "applied source synchronization has impossible provenance",
659 ));
660 }
661 }
662 Some(SourceSyncOutcome::NoChange) => {
663 if resource.stage != SourceSyncStage::ResolvingCommit
664 || resource.source_commit.is_none()
665 || resource.content_digest.is_none()
666 {
667 return Err(SourceWireDecodeError(
668 "unchanged source synchronization has impossible provenance",
669 ));
670 }
671 }
672 Some(SourceSyncOutcome::Failed | SourceSyncOutcome::Cancelled) => {}
673 None => validate_active_source_sync_provenance(resource)?,
674 }
675 Ok(())
676}
677
678fn validate_active_source_sync_provenance(
679 resource: &SourceSyncResource,
680) -> Result<(), SourceWireDecodeError> {
681 let has_commit = resource.source_commit.is_some();
682 let has_digest = resource.content_digest.is_some();
683 let valid = match resource.stage {
684 SourceSyncStage::Queued | SourceSyncStage::Fetching | SourceSyncStage::ResolvingCommit => {
685 !has_commit && !has_digest
686 }
687 SourceSyncStage::PreparingCandidate | SourceSyncStage::Compiling => {
688 has_commit && !has_digest
689 }
690 SourceSyncStage::Reloading => has_commit && has_digest,
691 };
692 if valid {
693 Ok(())
694 } else {
695 Err(SourceWireDecodeError(
696 "active source synchronization has impossible provenance",
697 ))
698 }
699}
700
701#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
702#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
703pub struct BeginSourceSyncResponse {
704 pub admission: SourceSyncAdmission,
705 pub sync: SourceSyncResource,
706}
707
708#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
709#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
710pub struct ListSourceSyncsResponse {
711 pub syncs: Vec<SourceSyncResource>,
712 pub next_cursor: Option<SourceSyncId>,
713}
714
715#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
717#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
718#[serde(try_from = "SourceDeployKeyWire")]
719pub struct SourceDeployKeyResponse {
720 pub credential_name: SshCredentialName,
721 pub public_key: Box<str>,
722 pub fingerprint: Box<str>,
723}
724
725#[derive(Deserialize)]
726struct SourceDeployKeyWire {
727 credential_name: SshCredentialName,
728 public_key: Box<str>,
729 fingerprint: Box<str>,
730}
731
732impl TryFrom<SourceDeployKeyWire> for SourceDeployKeyResponse {
733 type Error = SourceWireDecodeError;
734 fn try_from(wire: SourceDeployKeyWire) -> Result<Self, Self::Error> {
735 let public_key = wire.public_key.strip_prefix("ssh-ed25519 ");
736 let fingerprint = wire.fingerprint.strip_prefix("SHA256:");
737 if !public_key.is_some_and(|value| valid_unpadded_base64(value, 68))
738 || !fingerprint.is_some_and(|value| valid_unpadded_base64(value, 43))
739 {
740 return Err(SourceWireDecodeError(
741 "deploy public identity has invalid encoding",
742 ));
743 }
744 Ok(Self {
745 credential_name: wire.credential_name,
746 public_key: wire.public_key,
747 fingerprint: wire.fingerprint,
748 })
749 }
750}
751
752fn valid_unpadded_base64(value: &str, length: usize) -> bool {
753 value.len() == length
754 && value
755 .bytes()
756 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/'))
757}
758
759#[derive(Clone, Debug, Deserialize, Serialize)]
761#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
762#[serde(deny_unknown_fields)]
763pub struct ReconfigureSourceRequest {
764 pub remote: SshRemote,
765 pub branch: GitBranchName,
766 pub content_subdirectory: RepositoryContentSubdirectory,
767 pub credential_name: SshCredentialName,
768 pub poll_interval_seconds: SourcePollInterval,
769 pub expected_version: SourceConfigurationVersion,
770}
771
772#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
774#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
775#[serde(tag = "mode", rename_all = "snake_case")]
776pub enum SourceStatusResponse {
777 ExternalCheckout,
778 ManagedGit {
779 configuration: Box<ManagedSourceConfiguration>,
780 installed_commit: Option<Box<str>>,
781 content_digest: Option<Box<str>>,
782 active_sync: Option<Box<SourceSyncResource>>,
783 latest_sync: Option<Box<SourceSyncResource>>,
784 #[serde(serialize_with = "time::serde::rfc3339::option::serialize")]
785 #[cfg_attr(feature = "schema", schema(value_type = Option<String>, format = DateTime))]
786 next_poll_at: Option<OffsetDateTime>,
787 },
788}
789
790#[derive(Deserialize)]
791#[serde(tag = "mode", rename_all = "snake_case")]
792enum SourceStatusWire {
793 ExternalCheckout,
794 ManagedGit {
795 configuration: Box<ManagedSourceConfiguration>,
796 #[serde(default, deserialize_with = "deserialize_optional_source_commit")]
797 installed_commit: Option<Box<str>>,
798 #[serde(
799 default,
800 deserialize_with = "deserialize_optional_source_content_digest"
801 )]
802 content_digest: Option<Box<str>>,
803 active_sync: Option<Box<SourceSyncResource>>,
804 latest_sync: Option<Box<SourceSyncResource>>,
805 #[serde(default, deserialize_with = "deserialize_optional_utc_timestamp")]
806 next_poll_at: Option<OffsetDateTime>,
807 },
808}
809
810impl<'de> Deserialize<'de> for SourceStatusResponse {
811 fn deserialize<DeserializerType>(
812 deserializer: DeserializerType,
813 ) -> Result<Self, DeserializerType::Error>
814 where
815 DeserializerType: Deserializer<'de>,
816 {
817 match SourceStatusWire::deserialize(deserializer)? {
818 SourceStatusWire::ExternalCheckout => Ok(Self::ExternalCheckout),
819 SourceStatusWire::ManagedGit {
820 configuration,
821 installed_commit,
822 content_digest,
823 active_sync,
824 latest_sync,
825 next_poll_at,
826 } => {
827 if installed_commit.is_some() != content_digest.is_some() {
828 return Err(de::Error::custom(SourceWireDecodeError(
829 "installed source commit and content digest must appear together",
830 )));
831 }
832 if active_sync
833 .as_deref()
834 .is_some_and(|sync| sync.outcome.is_some())
835 {
836 return Err(de::Error::custom(SourceWireDecodeError(
837 "active source synchronization must be non-terminal",
838 )));
839 }
840 Ok(Self::ManagedGit {
841 configuration,
842 installed_commit,
843 content_digest,
844 active_sync,
845 latest_sync,
846 next_poll_at,
847 })
848 }
849 }
850 }
851}
852
853pub fn valid_source_commit(value: &str) -> bool {
855 [
856 (GIT_SHA1_SOURCE_COMMIT_PREFIX, 40),
857 (GIT_SHA256_SOURCE_COMMIT_PREFIX, 64),
858 ]
859 .into_iter()
860 .any(|(prefix, length)| {
861 value.strip_prefix(prefix).is_some_and(|hex| {
862 hex.len() == length
863 && hex
864 .bytes()
865 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
866 })
867 })
868}
869
870pub fn valid_source_content_digest(value: &str) -> bool {
871 value
872 .strip_prefix(SOURCE_CONTENT_DIGEST_PREFIX)
873 .is_some_and(valid_64_byte_lowercase_hex)
874}
875
876fn valid_64_byte_lowercase_hex(value: &str) -> bool {
877 value.len() == 64
878 && value
879 .bytes()
880 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
881}
882
883fn deserialize_optional_source_commit<'de, DeserializerType>(
884 deserializer: DeserializerType,
885) -> Result<Option<Box<str>>, DeserializerType::Error>
886where
887 DeserializerType: Deserializer<'de>,
888{
889 let value = Option::<Box<str>>::deserialize(deserializer)?;
890 if value
891 .as_deref()
892 .is_some_and(|commit| !valid_source_commit(commit))
893 {
894 Err(de::Error::custom(
895 "source commit must be one complete algorithm-qualified Git identity",
896 ))
897 } else {
898 Ok(value)
899 }
900}
901
902fn deserialize_optional_source_content_digest<'de, DeserializerType>(
903 deserializer: DeserializerType,
904) -> Result<Option<Box<str>>, DeserializerType::Error>
905where
906 DeserializerType: Deserializer<'de>,
907{
908 let value = Option::<Box<str>>::deserialize(deserializer)?;
909 if value
910 .as_deref()
911 .is_some_and(|digest| !valid_source_content_digest(digest))
912 {
913 Err(de::Error::custom(
914 "source content digest must use the complete content-b3-v1 encoding",
915 ))
916 } else {
917 Ok(value)
918 }
919}
920
921fn valid_host(value: &str) -> bool {
922 if let Ok(address) = value.parse::<Ipv4Addr>() {
923 return address.to_string() == value;
924 }
925 valid_dns_host(value) && !is_legacy_ipv4(value)
926}
927
928fn valid_dns_host(value: &str) -> bool {
929 !value.ends_with('.')
930 && value.split('.').all(|label| {
931 !label.is_empty()
932 && label.len() <= 63
933 && !label.starts_with('-')
934 && !label.ends_with('-')
935 && label
936 .bytes()
937 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
938 })
939}
940
941fn is_legacy_ipv4(value: &str) -> bool {
944 let mut components = [0_u64; 4];
945 let mut count = 0;
946 for component in value.split('.') {
947 if count == components.len() {
948 return false;
949 }
950 let Some(component) = parse_legacy_ipv4_component(component) else {
951 return false;
952 };
953 components[count] = component;
954 count += 1;
955 }
956
957 match count {
958 1 => components[0] <= u32::MAX.into(),
959 2 => components[0] <= u8::MAX.into() && components[1] <= 0x00ff_ffff,
960 3 => {
961 components[0] <= u8::MAX.into()
962 && components[1] <= u8::MAX.into()
963 && components[2] <= u16::MAX.into()
964 }
965 4 => components
966 .iter()
967 .all(|component| *component <= u8::MAX.into()),
968 _ => false,
969 }
970}
971
972fn parse_legacy_ipv4_component(value: &str) -> Option<u64> {
973 let (digits, radix) = if let Some(hexadecimal) = value.strip_prefix("0x") {
974 (hexadecimal, 16)
975 } else if value.len() > 1 && value.starts_with('0') {
976 (value, 8)
977 } else {
978 (value, 10)
979 };
980 if digits.is_empty() {
981 None
982 } else {
983 u64::from_str_radix(digits, radix).ok()
984 }
985}
986
987fn valid_repository_path(value: &str) -> bool {
988 let path = value.strip_prefix('/').unwrap_or(value);
989 !path.is_empty()
990 && !path.starts_with('-')
991 && !value.contains("//")
992 && path.split('/').all(valid_path_segment)
993 && value
994 .bytes()
995 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.'))
996}
997
998fn valid_content_subdirectory(value: &str) -> bool {
999 value == "."
1000 || (!value.starts_with('-')
1001 && !value.starts_with('/')
1002 && !value.ends_with('/')
1003 && !value.contains("//")
1004 && value.split('/').all(valid_path_segment)
1005 && value.bytes().all(|byte| {
1006 byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.')
1007 }))
1008}
1009
1010fn valid_path_segment(segment: &str) -> bool {
1011 !segment.is_empty() && !matches!(segment, "." | "..")
1012}
1013
1014fn valid_branch(value: &str) -> bool {
1015 !value.starts_with('-')
1016 && !value.starts_with('/')
1017 && !value.ends_with('/')
1018 && !value.ends_with('.')
1019 && !value.ends_with(".lock")
1020 && value != "@"
1021 && !value.contains("..")
1022 && !value.contains("@{")
1023 && !value.contains("//")
1024 && value.split('/').all(|segment| {
1025 valid_path_segment(segment) && !segment.starts_with('.') && !segment.ends_with('.')
1026 })
1027 && value.bytes().all(|byte| {
1028 !byte.is_ascii_control()
1029 && !matches!(byte, b' ' | b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
1030 })
1031}
1032
1033fn deserialize_utc_timestamp<'de, DeserializerType>(
1034 deserializer: DeserializerType,
1035) -> Result<OffsetDateTime, DeserializerType::Error>
1036where
1037 DeserializerType: Deserializer<'de>,
1038{
1039 let timestamp = time::serde::rfc3339::deserialize(deserializer)?;
1040 if timestamp.offset() == UtcOffset::UTC {
1041 Ok(timestamp)
1042 } else {
1043 Err(de::Error::custom(
1044 "source timestamp must use the UTC offset",
1045 ))
1046 }
1047}
1048
1049fn deserialize_optional_utc_timestamp<'de, DeserializerType>(
1050 deserializer: DeserializerType,
1051) -> Result<Option<OffsetDateTime>, DeserializerType::Error>
1052where
1053 DeserializerType: Deserializer<'de>,
1054{
1055 let timestamp = time::serde::rfc3339::option::deserialize(deserializer)?;
1056 match timestamp {
1057 Some(timestamp) if timestamp.offset() != UtcOffset::UTC => Err(de::Error::custom(
1058 "source timestamp must use the UTC offset",
1059 )),
1060 timestamp => Ok(timestamp),
1061 }
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066 use super::*;
1067
1068 fn remote() -> SshRemote {
1069 SshRemote {
1070 user: SshRemoteUser::parse("git").unwrap(),
1071 host: SshRemoteHost::parse("git.example.test").unwrap(),
1072 port: SshRemotePort::new(22).unwrap(),
1073 repository_path: SshRepositoryPath::parse("publisher/site.git").unwrap(),
1074 }
1075 }
1076
1077 #[test]
1078 fn structured_remote_rejects_embedded_credentials_and_ambiguous_fields() {
1079 for invalid in ["git@forge", "git:user", "git user", "-o", ""] {
1080 assert!(
1081 SshRemoteUser::parse(invalid).is_err(),
1082 "accepted {invalid:?}"
1083 );
1084 }
1085 for invalid in ["User@host", "EXAMPLE.test", "example.test.", "-host", ""] {
1086 assert!(
1087 SshRemoteHost::parse(invalid).is_err(),
1088 "accepted {invalid:?}"
1089 );
1090 }
1091 for invalid in [
1092 "../site.git",
1093 "org//site.git",
1094 "-upload-pack",
1095 "~/site.git",
1096 "~git/site.git",
1097 "",
1098 ] {
1099 assert!(
1100 SshRepositoryPath::parse(invalid).is_err(),
1101 "accepted {invalid:?}"
1102 );
1103 }
1104 assert!(SshRemotePort::new(0).is_none());
1105 assert_eq!(serde_json::to_value(remote()).unwrap()["port"], 22);
1106 }
1107
1108 #[test]
1109 fn ssh_host_accepts_canonical_ipv4_and_dns_but_rejects_legacy_ipv4_text() {
1110 for valid in [
1111 "127.0.0.1",
1112 "203.0.113.42",
1113 "git.example.test",
1114 "git-1.example.test",
1115 ] {
1116 assert!(SshRemoteHost::parse(valid).is_ok(), "rejected {valid:?}");
1117 }
1118 for invalid in ["127.1", "2130706433", "0177.0.0.1", "0x7f000001"] {
1119 assert!(
1120 SshRemoteHost::parse(invalid).is_err(),
1121 "accepted {invalid:?}"
1122 );
1123 }
1124 }
1125
1126 #[test]
1127 fn branch_subdirectory_credential_and_poll_values_are_bounded() {
1128 for branch in ["main", "release/v1"] {
1129 assert!(GitBranchName::parse(branch).is_ok());
1130 }
1131 for branch in ["-main", "refs/heads/main.lock", "topic..next", "topic@{1}"] {
1132 assert!(GitBranchName::parse(branch).is_err(), "accepted {branch:?}");
1133 }
1134 for path in [".", "publication", "sites/main"] {
1135 assert!(RepositoryContentSubdirectory::parse(path).is_ok());
1136 }
1137 for path in ["/publication", "../publication", "sites//main"] {
1138 assert!(RepositoryContentSubdirectory::parse(path).is_err());
1139 }
1140 assert!(SshCredentialName::parse("deploy-key-1").is_ok());
1141 assert!(SshCredentialName::parse("Deploy Key").is_err());
1142 assert!(SourcePollInterval::from_seconds(MIN_SOURCE_POLL_INTERVAL_SECONDS).is_some());
1143 assert!(SourcePollInterval::from_seconds(MAX_SOURCE_POLL_INTERVAL_SECONDS).is_some());
1144 assert!(SourcePollInterval::from_seconds(MIN_SOURCE_POLL_INTERVAL_SECONDS - 1).is_none());
1145 assert!(SourcePollInterval::from_seconds(MAX_SOURCE_POLL_INTERVAL_SECONDS + 1).is_none());
1146 }
1147
1148 #[test]
1149 fn source_commit_wire_identity_requires_an_algorithm_and_exact_lowercase_hex() {
1150 assert!(valid_source_commit(&format!(
1151 "{GIT_SHA1_SOURCE_COMMIT_PREFIX}{}",
1152 "ab".repeat(20)
1153 )));
1154 assert!(valid_source_commit(&format!(
1155 "{GIT_SHA256_SOURCE_COMMIT_PREFIX}{}",
1156 "cd".repeat(32)
1157 )));
1158 for invalid in [
1159 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1160 "git-sha1:aaaaaaaa",
1161 "git-sha1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
1162 "git-sha512:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1163 ] {
1164 assert!(!valid_source_commit(invalid), "accepted {invalid}");
1165 }
1166 }
1167}