Skip to main content

maincopy_shared/
source.rs

1//! Validated managed-source values and versioned source-sync wire contracts.
2
3use 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/// A canonical IPv4 address or unambiguous lowercase DNS name used only as a
180/// structured SSH endpoint.
181#[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/// A nonzero TCP port for the structured SSH endpoint.
240#[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/// A positive source-configuration version representable by SQLite.
272#[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/// A bounded whole-second managed-source poll interval.
306#[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/// Public SSH identity derived from the selected protected deploy key.
716#[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/// Proposed online settings. The expected version identifies the installed head.
760#[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/// Current source mode and its non-secret runtime state.
773#[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
853/// Validates the complete, algorithm-qualified Git identity used on the wire.
854pub 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
941// OpenSSH accepts legacy inet_aton-style numbers. Do not reinterpret one of
942// those spellings as a DNS name after Rust's strict IPv4 parser rejects it.
943fn 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}