Skip to main content

agentics_domain/models/
urls.rs

1//! Validated URL and remote-locator types used by Agentics public contracts.
2
3use std::borrow::Cow;
4use std::fmt;
5use std::str::FromStr;
6
7use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use url::Url;
10
11use super::names::MoltbookSubmoltName;
12
13/// User-facing validation message for GitHub repository remotes.
14pub const GITHUB_REPO_REMOTE_ERROR_MESSAGE: &str = "repo_url must be a GitHub HTTPS repository URL or git@github.com:{owner}/{repo}.git SSH remote";
15
16/// User-facing validation message for GitHub pull request URLs.
17pub const GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE: &str = "pr_url must be a GitHub HTTPS pull request URL like https://github.com/{owner}/{repo}/pull/{number}";
18
19/// User-facing validation message for external data URLs.
20pub const EXTERNAL_DATA_URL_ERROR_MESSAGE: &str = "external data url must be an HTTPS URL";
21
22/// User-facing validation message for the platform Moltbook Submolt URL.
23pub const MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE: &str =
24    "moltbook submolt url must be https://www.moltbook.com/m/{submolt-name}";
25
26/// User-facing validation message for Moltbook discussion post URLs.
27pub const MOLTBOOK_POST_URL_ERROR_MESSAGE: &str =
28    "moltbook post url must be https://www.moltbook.com/post/{post-id}";
29
30/// Validation message for GitHub App redirect URLs.
31pub const GITHUB_APP_REDIRECT_URL_ERROR_MESSAGE: &str =
32    "GitHub sign-in redirect URL must be an absolute HTTP(S) URL without query or fragment";
33
34/// Validation message for GitHub App authorization endpoint URLs.
35pub const GITHUB_APP_AUTHORIZE_URL_ERROR_MESSAGE: &str =
36    "GitHub sign-in authorize URL must be https://github.com/login/oauth/authorize";
37pub const GITHUB_SIGN_IN_AUTHORIZATION_URL_ERROR_MESSAGE: &str =
38    "GitHub sign-in authorization URL must be an HTTPS GitHub authorize URL without fragment";
39
40/// Validation message for GitHub App token endpoint URLs.
41pub const GITHUB_APP_TOKEN_URL_ERROR_MESSAGE: &str =
42    "GitHub sign-in token URL must be https://github.com/login/oauth/access_token";
43
44/// Validation message for the GitHub user API URL.
45pub const GITHUB_API_USER_URL_ERROR_MESSAGE: &str =
46    "github API user URL must be https://api.github.com/user";
47
48/// Validation failure for URL-like Agentics contract fields.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct UrlFieldError {
51    message: &'static str,
52}
53
54impl UrlFieldError {
55    const fn new(message: &'static str) -> Self {
56        Self { message }
57    }
58}
59
60impl fmt::Display for UrlFieldError {
61    /// Handles fmt for this module.
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        f.write_str(self.message)
64    }
65}
66
67impl std::error::Error for UrlFieldError {}
68
69macro_rules! impl_string_url_serde {
70    ($type_name:ident) => {
71        impl Serialize for $type_name {
72            /// Handles serialize for this module.
73            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
74            where
75                S: Serializer,
76            {
77                serializer.serialize_str(self.as_str())
78            }
79        }
80
81        impl<'de> Deserialize<'de> for $type_name {
82            /// Handles deserialize for this module.
83            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
84            where
85                D: Deserializer<'de>,
86            {
87                let value = String::deserialize(deserializer)?;
88                Self::from_str(&value).map_err(serde::de::Error::custom)
89            }
90        }
91    };
92}
93
94macro_rules! impl_string_url_schema {
95    ($type_name:ident, $schema_name:literal, $pattern:literal) => {
96        impl JsonSchema for $type_name {
97            /// Handles inline schema for this module.
98            fn inline_schema() -> bool {
99                true
100            }
101
102            /// Handles schema name for this module.
103            fn schema_name() -> Cow<'static, str> {
104                $schema_name.into()
105            }
106
107            /// Handles json schema for this module.
108            fn json_schema(_: &mut SchemaGenerator) -> Schema {
109                json_schema!({
110                    "type": "string",
111                    "format": "uri",
112                    "pattern": $pattern
113                })
114            }
115        }
116    };
117}
118
119/// GitHub repository remote syntax accepted for challenge review record provenance.
120#[derive(Debug, Clone, PartialEq, Eq, Hash)]
121pub enum GithubRepoRemote {
122    /// HTTPS repository URL such as `https://github.com/agentics-reifying/agentics-challenges`.
123    Https {
124        /// Original validated HTTPS URL.
125        url: Url,
126        /// Canonical owner/repository key for uniqueness and authorization.
127        key: GithubRepoKey,
128    },
129    /// SSH repository remote such as `git@github.com:agentics-reifying/agentics-challenges.git`.
130    Ssh(GithubSshRepoRemote),
131}
132
133impl GithubRepoRemote {
134    /// Parse and validate a GitHub repository remote.
135    pub fn try_new(value: impl AsRef<str>) -> Result<Self, UrlFieldError> {
136        value.as_ref().parse()
137    }
138
139    /// Borrow the canonical string representation.
140    pub fn as_str(&self) -> &str {
141        match self {
142            Self::Https { url, .. } => url.as_str(),
143            Self::Ssh(remote) => remote.as_str(),
144        }
145    }
146
147    /// Return the canonical GitHub owner/repository key.
148    ///
149    /// This key is not a URL and does not preserve the submitted remote syntax.
150    /// It collapses accepted GitHub HTTPS and SSH remotes for the same
151    /// repository into one `owner/repo` identity for duplicate detection and
152    /// authorization checks.
153    pub fn repository_key(&self) -> &GithubRepoKey {
154        match self {
155            Self::Https { key, .. } => key,
156            Self::Ssh(remote) => remote.repository_key(),
157        }
158    }
159}
160
161impl fmt::Display for GithubRepoRemote {
162    /// Handles fmt for this module.
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        f.write_str(self.as_str())
165    }
166}
167
168impl FromStr for GithubRepoRemote {
169    type Err = UrlFieldError;
170
171    /// Handles from str for this module.
172    fn from_str(value: &str) -> Result<Self, Self::Err> {
173        let value = value.trim();
174        if value.starts_with("git@github.com:") {
175            return Ok(Self::Ssh(GithubSshRepoRemote::try_new(value)?));
176        }
177
178        let url = parse_url(value, GITHUB_REPO_REMOTE_ERROR_MESSAGE)?;
179        let key = github_https_repo_key(&url)?;
180        Ok(Self::Https { url, key })
181    }
182}
183
184impl Serialize for GithubRepoRemote {
185    /// Handles serialize for this module.
186    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
187    where
188        S: Serializer,
189    {
190        serializer.serialize_str(self.as_str())
191    }
192}
193
194impl<'de> Deserialize<'de> for GithubRepoRemote {
195    /// Handles deserialize for this module.
196    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
197    where
198        D: Deserializer<'de>,
199    {
200        let value = String::deserialize(deserializer)?;
201        Self::from_str(&value).map_err(serde::de::Error::custom)
202    }
203}
204
205impl JsonSchema for GithubRepoRemote {
206    /// Handles inline schema for this module.
207    fn inline_schema() -> bool {
208        true
209    }
210
211    /// Handles schema name for this module.
212    fn schema_name() -> Cow<'static, str> {
213        "GithubRepoRemote".into()
214    }
215
216    /// Handles json schema for this module.
217    fn json_schema(_: &mut SchemaGenerator) -> Schema {
218        json_schema!({
219            "type": "string",
220            "pattern": r"^(https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?|git@github\.com:[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\.git)$"
221        })
222    }
223}
224
225/// Validated GitHub SSH repository remote.
226#[derive(Debug, Clone, PartialEq, Eq, Hash)]
227pub struct GithubSshRepoRemote {
228    value: String,
229    key: GithubRepoKey,
230}
231
232impl GithubSshRepoRemote {
233    /// Handles try new for this module.
234    fn try_new(value: &str) -> Result<Self, UrlFieldError> {
235        reject_whitespace_or_control(value, GITHUB_REPO_REMOTE_ERROR_MESSAGE)?;
236        let Some(rest) = value.strip_prefix("git@github.com:") else {
237            return Err(UrlFieldError::new(GITHUB_REPO_REMOTE_ERROR_MESSAGE));
238        };
239        let Some((owner, repo_with_suffix)) = rest.split_once('/') else {
240            return Err(UrlFieldError::new(GITHUB_REPO_REMOTE_ERROR_MESSAGE));
241        };
242        if repo_with_suffix.contains('/') {
243            return Err(UrlFieldError::new(GITHUB_REPO_REMOTE_ERROR_MESSAGE));
244        }
245        let Some(repo) = repo_with_suffix.strip_suffix(".git") else {
246            return Err(UrlFieldError::new(GITHUB_REPO_REMOTE_ERROR_MESSAGE));
247        };
248        let key = GithubRepoKey::try_new(owner, repo)?;
249        Ok(Self {
250            value: format!("git@github.com:{owner}/{repo}.git"),
251            key,
252        })
253    }
254
255    /// Returns str in the representation required by callers.
256    fn as_str(&self) -> &str {
257        &self.value
258    }
259
260    /// Handles repository key for this module.
261    fn repository_key(&self) -> &GithubRepoKey {
262        &self.key
263    }
264}
265
266/// Canonical GitHub repository identity used for duplicate detection.
267///
268/// The string is always lowercase `owner/repo`. Keep the original
269/// `GithubRepoRemote` when provenance or display should preserve whether the
270/// contributor submitted an HTTPS URL or SSH remote.
271#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
272pub struct GithubRepoKey(String);
273
274impl GithubRepoKey {
275    /// Handles try new for this module.
276    fn try_new(owner: &str, repo: &str) -> Result<Self, UrlFieldError> {
277        validate_github_path_segment(owner, GITHUB_REPO_REMOTE_ERROR_MESSAGE)?;
278        validate_github_path_segment(repo, GITHUB_REPO_REMOTE_ERROR_MESSAGE)?;
279        Ok(Self(format!(
280            "{}/{}",
281            owner.to_ascii_lowercase(),
282            repo.to_ascii_lowercase()
283        )))
284    }
285
286    /// Borrow the canonical `owner/repo` key.
287    pub fn as_str(&self) -> &str {
288        &self.0
289    }
290}
291
292impl fmt::Display for GithubRepoKey {
293    /// Handles fmt for this module.
294    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295        f.write_str(self.as_str())
296    }
297}
298
299/// GitHub HTTPS pull request URL.
300#[derive(Debug, Clone, PartialEq, Eq, Hash)]
301pub struct GithubPullRequestUrl(Url);
302
303impl GithubPullRequestUrl {
304    /// Parse and validate a GitHub pull request URL.
305    pub fn try_new(value: impl AsRef<str>) -> Result<Self, UrlFieldError> {
306        value.as_ref().parse()
307    }
308
309    /// Borrow the canonical string representation.
310    pub fn as_str(&self) -> &str {
311        self.0.as_str()
312    }
313
314    /// Return the canonical GitHub owner/repository key.
315    pub fn repository_key(&self) -> Result<GithubRepoKey, UrlFieldError> {
316        github_pull_request_parts(&self.0)
317            .map(|(owner, repo, _number)| GithubRepoKey(format!("{owner}/{repo}")))
318    }
319
320    /// Return the canonical decimal pull request number.
321    pub fn number(&self) -> Result<String, UrlFieldError> {
322        github_pull_request_parts(&self.0).map(|(_owner, _repo, number)| number)
323    }
324}
325
326impl fmt::Display for GithubPullRequestUrl {
327    /// Handles fmt for this module.
328    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
329        f.write_str(self.as_str())
330    }
331}
332
333impl FromStr for GithubPullRequestUrl {
334    type Err = UrlFieldError;
335
336    /// Handles from str for this module.
337    fn from_str(value: &str) -> Result<Self, Self::Err> {
338        let url = parse_url(value.trim(), GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE)?;
339        validate_github_https_pull_request_url(&url)?;
340        Ok(Self(url))
341    }
342}
343
344impl_string_url_serde!(GithubPullRequestUrl);
345impl_string_url_schema!(
346    GithubPullRequestUrl,
347    "GithubPullRequestUrl",
348    r"^https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/pull/[0-9]+$"
349);
350
351/// External HTTPS URL referenced by challenge-owned setup metadata.
352#[derive(Debug, Clone, PartialEq, Eq, Hash)]
353pub struct ExternalDataUrl(Url);
354
355impl ExternalDataUrl {
356    /// Parse and validate an external data URL.
357    pub fn try_new(value: impl AsRef<str>) -> Result<Self, UrlFieldError> {
358        value.as_ref().parse()
359    }
360
361    /// Borrow the canonical string representation.
362    pub fn as_str(&self) -> &str {
363        self.0.as_str()
364    }
365}
366
367impl fmt::Display for ExternalDataUrl {
368    /// Handles fmt for this module.
369    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
370        f.write_str(self.as_str())
371    }
372}
373
374impl FromStr for ExternalDataUrl {
375    type Err = UrlFieldError;
376
377    /// Handles from str for this module.
378    fn from_str(value: &str) -> Result<Self, Self::Err> {
379        let url = parse_url(value.trim(), EXTERNAL_DATA_URL_ERROR_MESSAGE)?;
380        validate_https_url(&url, EXTERNAL_DATA_URL_ERROR_MESSAGE)?;
381        Ok(Self(url))
382    }
383}
384
385impl_string_url_serde!(ExternalDataUrl);
386impl_string_url_schema!(ExternalDataUrl, "ExternalDataUrl", r"^https://[^?#]+$");
387
388/// Validated Moltbook Submolt URL for the global Agentics communication channel.
389#[derive(Debug, Clone, PartialEq, Eq, Hash)]
390pub struct MoltbookSubmoltUrl(Url);
391
392impl MoltbookSubmoltUrl {
393    /// Parse and validate a Moltbook Submolt URL.
394    pub fn try_new(value: impl AsRef<str>) -> Result<Self, UrlFieldError> {
395        value.as_ref().parse()
396    }
397
398    /// Borrow the canonical string representation.
399    pub fn as_str(&self) -> &str {
400        self.0.as_str()
401    }
402
403    /// Return the Submolt name carried by this URL.
404    pub fn submolt_name(&self) -> Result<MoltbookSubmoltName, UrlFieldError> {
405        moltbook_submolt_name(&self.0)
406    }
407}
408
409impl fmt::Display for MoltbookSubmoltUrl {
410    /// Handles fmt for this module.
411    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
412        f.write_str(self.as_str())
413    }
414}
415
416impl FromStr for MoltbookSubmoltUrl {
417    type Err = UrlFieldError;
418
419    /// Handles from str for this module.
420    fn from_str(value: &str) -> Result<Self, Self::Err> {
421        let url = parse_url(value.trim(), MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE)?;
422        validate_moltbook_submolt_url(&url)?;
423        Ok(Self(url))
424    }
425}
426
427impl_string_url_serde!(MoltbookSubmoltUrl);
428impl_string_url_schema!(
429    MoltbookSubmoltUrl,
430    "MoltbookSubmoltUrl",
431    r"^https://www\.moltbook\.com/m/[a-z0-9](?:[a-z0-9]|-(?!-)){0,28}[a-z0-9]$"
432);
433
434/// Validated Moltbook post URL used as an operator-attached challenge discussion anchor.
435#[derive(Debug, Clone, PartialEq, Eq, Hash)]
436pub struct MoltbookPostUrl(Url);
437
438impl MoltbookPostUrl {
439    /// Parse and validate a Moltbook post URL.
440    pub fn try_new(value: impl AsRef<str>) -> Result<Self, UrlFieldError> {
441        value.as_ref().parse()
442    }
443
444    /// Borrow the canonical string representation.
445    pub fn as_str(&self) -> &str {
446        self.0.as_str()
447    }
448}
449
450impl fmt::Display for MoltbookPostUrl {
451    /// Handles fmt for this module.
452    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
453        f.write_str(self.as_str())
454    }
455}
456
457impl FromStr for MoltbookPostUrl {
458    type Err = UrlFieldError;
459
460    /// Handles from str for this module.
461    fn from_str(value: &str) -> Result<Self, Self::Err> {
462        let url = parse_url(value.trim(), MOLTBOOK_POST_URL_ERROR_MESSAGE)?;
463        validate_moltbook_post_url(&url)?;
464        Ok(Self(url))
465    }
466}
467
468impl_string_url_serde!(MoltbookPostUrl);
469impl_string_url_schema!(
470    MoltbookPostUrl,
471    "MoltbookPostUrl",
472    r"^https://www\.moltbook\.com/post/[A-Za-z0-9_-]+$"
473);
474
475macro_rules! define_url_wrapper {
476    ($type_name:ident, $schema_name:literal, $validator:ident, $message:ident) => {
477        #[derive(Debug, Clone, PartialEq, Eq, Hash)]
478        pub struct $type_name(Url);
479
480        impl $type_name {
481            /// Parse and validate this configured URL.
482            pub fn try_new(value: impl AsRef<str>) -> Result<Self, UrlFieldError> {
483                value.as_ref().parse()
484            }
485
486            /// Borrow the canonical string representation.
487            pub fn as_str(&self) -> &str {
488                self.0.as_str()
489            }
490
491            /// Clone the underlying URL for query construction.
492            pub fn to_url(&self) -> Url {
493                self.0.clone()
494            }
495        }
496
497        impl fmt::Display for $type_name {
498            /// Handles fmt for this module.
499            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
500                f.write_str(self.as_str())
501            }
502        }
503
504        impl FromStr for $type_name {
505            type Err = UrlFieldError;
506
507            /// Handles from str for this module.
508            fn from_str(value: &str) -> Result<Self, Self::Err> {
509                let url = parse_url(value.trim(), $message)?;
510                $validator(&url)?;
511                Ok(Self(url))
512            }
513        }
514
515        impl_string_url_serde!($type_name);
516        impl_string_url_schema!($type_name, $schema_name, r"^https?://[^?#]+$");
517    };
518}
519
520define_url_wrapper!(
521    GithubAppRedirectUrl,
522    "GithubAppRedirectUrl",
523    validate_github_app_redirect_url,
524    GITHUB_APP_REDIRECT_URL_ERROR_MESSAGE
525);
526define_url_wrapper!(
527    GithubAppAuthorizeUrl,
528    "GithubAppAuthorizeUrl",
529    validate_github_app_authorize_url,
530    GITHUB_APP_AUTHORIZE_URL_ERROR_MESSAGE
531);
532define_url_wrapper!(
533    GithubAppTokenUrl,
534    "GithubAppTokenUrl",
535    validate_github_app_token_url,
536    GITHUB_APP_TOKEN_URL_ERROR_MESSAGE
537);
538define_url_wrapper!(
539    GithubApiUserUrl,
540    "GithubApiUserUrl",
541    validate_github_api_user_url,
542    GITHUB_API_USER_URL_ERROR_MESSAGE
543);
544
545/// GitHub sign-in authorization URL with request query parameters.
546#[derive(Debug, Clone, PartialEq, Eq, Hash)]
547pub struct GithubSignInAuthorizationUrl(Url);
548
549impl GithubSignInAuthorizationUrl {
550    /// Validate a generated authorization URL.
551    pub fn try_from_url(url: Url) -> Result<Self, UrlFieldError> {
552        validate_github_sign_in_authorization_url(&url)?;
553        Ok(Self(url))
554    }
555
556    /// Borrow the canonical string representation.
557    pub fn as_str(&self) -> &str {
558        self.0.as_str()
559    }
560}
561
562impl fmt::Display for GithubSignInAuthorizationUrl {
563    /// Handles fmt for this module.
564    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
565        f.write_str(self.as_str())
566    }
567}
568
569impl FromStr for GithubSignInAuthorizationUrl {
570    type Err = UrlFieldError;
571
572    /// Handles from str for this module.
573    fn from_str(value: &str) -> Result<Self, Self::Err> {
574        let url = parse_url(value.trim(), GITHUB_SIGN_IN_AUTHORIZATION_URL_ERROR_MESSAGE)?;
575        Self::try_from_url(url)
576    }
577}
578
579impl_string_url_serde!(GithubSignInAuthorizationUrl);
580impl_string_url_schema!(
581    GithubSignInAuthorizationUrl,
582    "GithubSignInAuthorizationUrl",
583    r"^https://github\.com/login/oauth/authorize\?[^#]+$"
584);
585
586/// Parses url from an external boundary string.
587fn parse_url(value: &str, message: &'static str) -> Result<Url, UrlFieldError> {
588    reject_whitespace_or_control(value, message)?;
589    Url::parse(value).map_err(|_| UrlFieldError::new(message))
590}
591
592/// Handles github https repo key for this module.
593fn github_https_repo_key(url: &Url) -> Result<GithubRepoKey, UrlFieldError> {
594    validate_github_https_base(url, GITHUB_REPO_REMOTE_ERROR_MESSAGE)?;
595    let segments = github_path_segments(url, GITHUB_REPO_REMOTE_ERROR_MESSAGE)?;
596    let [owner, repo_with_suffix] = segments.as_slice() else {
597        return Err(UrlFieldError::new(GITHUB_REPO_REMOTE_ERROR_MESSAGE));
598    };
599    let repo = repo_with_suffix
600        .strip_suffix(".git")
601        .unwrap_or(repo_with_suffix.as_str());
602    GithubRepoKey::try_new(owner, repo)
603}
604
605/// Validates github https pull request url invariants for this contract.
606fn validate_github_https_pull_request_url(url: &Url) -> Result<(), UrlFieldError> {
607    validate_github_https_base(url, GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE)?;
608    github_pull_request_parts(url).map(|_| ())
609}
610
611/// Return canonical repository and pull-request number parts from a validated PR URL.
612fn github_pull_request_parts(url: &Url) -> Result<(String, String, String), UrlFieldError> {
613    let segments = github_path_segments(url, GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE)?;
614    let [owner, repo, pull, number] = segments.as_slice() else {
615        return Err(UrlFieldError::new(GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE));
616    };
617    if pull != "pull" {
618        return Err(UrlFieldError::new(GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE));
619    }
620    validate_github_path_segment(owner, GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE)?;
621    validate_github_path_segment(repo, GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE)?;
622    if number.is_empty() || !number.bytes().all(|byte| byte.is_ascii_digit()) {
623        return Err(UrlFieldError::new(GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE));
624    }
625    Ok((
626        owner.to_ascii_lowercase(),
627        repo.to_ascii_lowercase(),
628        number.to_string(),
629    ))
630}
631
632/// Validates GitHub App redirect URL invariants for this contract.
633fn validate_github_app_redirect_url(url: &Url) -> Result<(), UrlFieldError> {
634    if !matches!(url.scheme(), "http" | "https")
635        || url.cannot_be_a_base()
636        || url.host_str().is_none()
637        || url_has_userinfo(url)
638        || url.query().is_some()
639        || url.fragment().is_some()
640    {
641        return Err(UrlFieldError::new(GITHUB_APP_REDIRECT_URL_ERROR_MESSAGE));
642    }
643    Ok(())
644}
645
646/// Validates GitHub App authorization endpoint invariants for this contract.
647fn validate_github_app_authorize_url(url: &Url) -> Result<(), UrlFieldError> {
648    validate_exact_https_url(
649        url,
650        "github.com",
651        "/login/oauth/authorize",
652        GITHUB_APP_AUTHORIZE_URL_ERROR_MESSAGE,
653    )
654}
655
656/// Validates generated GitHub sign-in authorization URL invariants.
657fn validate_github_sign_in_authorization_url(url: &Url) -> Result<(), UrlFieldError> {
658    if url.scheme() != "https"
659        || url.cannot_be_a_base()
660        || url.host_str() != Some("github.com")
661        || url.port().is_some()
662        || url.path() != "/login/oauth/authorize"
663        || url.query().is_none()
664        || url.fragment().is_some()
665    {
666        return Err(UrlFieldError::new(
667            GITHUB_SIGN_IN_AUTHORIZATION_URL_ERROR_MESSAGE,
668        ));
669    }
670    Ok(())
671}
672
673/// Validates GitHub App token endpoint invariants for this contract.
674fn validate_github_app_token_url(url: &Url) -> Result<(), UrlFieldError> {
675    validate_exact_https_url(
676        url,
677        "github.com",
678        "/login/oauth/access_token",
679        GITHUB_APP_TOKEN_URL_ERROR_MESSAGE,
680    )
681}
682
683/// Validates github api user url invariants for this contract.
684fn validate_github_api_user_url(url: &Url) -> Result<(), UrlFieldError> {
685    validate_exact_https_url(
686        url,
687        "api.github.com",
688        "/user",
689        GITHUB_API_USER_URL_ERROR_MESSAGE,
690    )
691}
692
693/// Validates Moltbook Submolt URL invariants for this contract.
694fn validate_moltbook_submolt_url(url: &Url) -> Result<(), UrlFieldError> {
695    validate_moltbook_base(url, MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE)?;
696    moltbook_submolt_name(url).map(|_| ())
697}
698
699/// Validates Moltbook post URL invariants for this contract.
700fn validate_moltbook_post_url(url: &Url) -> Result<(), UrlFieldError> {
701    validate_moltbook_base(url, MOLTBOOK_POST_URL_ERROR_MESSAGE)?;
702    let segments = moltbook_path_segments(url, MOLTBOOK_POST_URL_ERROR_MESSAGE)?;
703    let [post, post_id] = segments.as_slice() else {
704        return Err(UrlFieldError::new(MOLTBOOK_POST_URL_ERROR_MESSAGE));
705    };
706    if post != "post" || !has_moltbook_post_id_syntax(post_id) {
707        return Err(UrlFieldError::new(MOLTBOOK_POST_URL_ERROR_MESSAGE));
708    }
709    Ok(())
710}
711
712/// Return the Moltbook Submolt name from a validated Submolt URL.
713fn moltbook_submolt_name(url: &Url) -> Result<MoltbookSubmoltName, UrlFieldError> {
714    let segments = moltbook_path_segments(url, MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE)?;
715    let [m, name] = segments.as_slice() else {
716        return Err(UrlFieldError::new(MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE));
717    };
718    if m != "m" {
719        return Err(UrlFieldError::new(MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE));
720    }
721    let parsed = MoltbookSubmoltName::try_new(name.to_string())
722        .map_err(|_| UrlFieldError::new(MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE))?;
723    if parsed.as_str() != name {
724        return Err(UrlFieldError::new(MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE));
725    }
726    Ok(parsed)
727}
728
729/// Validate the host and URL decorations shared by all Moltbook links.
730fn validate_moltbook_base(url: &Url, message: &'static str) -> Result<(), UrlFieldError> {
731    validate_https_url(url, message)?;
732    if url.host_str() != Some("www.moltbook.com")
733        || url.port().is_some()
734        || !url.username().is_empty()
735        || url.password().is_some()
736    {
737        return Err(UrlFieldError::new(message));
738    }
739    Ok(())
740}
741
742/// Return non-empty Moltbook path segments.
743fn moltbook_path_segments(url: &Url, message: &'static str) -> Result<Vec<String>, UrlFieldError> {
744    let Some(segments) = url.path_segments() else {
745        return Err(UrlFieldError::new(message));
746    };
747    let segments: Vec<String> = segments.map(ToString::to_string).collect();
748    if segments.iter().any(|segment| segment.is_empty()) {
749        return Err(UrlFieldError::new(message));
750    }
751    Ok(segments)
752}
753
754/// Validate the opaque post id syntax accepted by Moltbook public post URLs.
755fn has_moltbook_post_id_syntax(value: &str) -> bool {
756    !value.is_empty()
757        && value
758            .bytes()
759            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
760}
761
762/// Validates exact https url invariants for this contract.
763fn validate_exact_https_url(
764    url: &Url,
765    host: &str,
766    path: &str,
767    message: &'static str,
768) -> Result<(), UrlFieldError> {
769    validate_https_url(url, message)?;
770    if url.host_str() != Some(host)
771        || url.port().is_some()
772        || url_has_userinfo(url)
773        || url.path() != path
774    {
775        return Err(UrlFieldError::new(message));
776    }
777    Ok(())
778}
779
780/// Returns whether the URL includes username or password userinfo.
781fn url_has_userinfo(url: &Url) -> bool {
782    !url.username().is_empty() || url.password().is_some()
783}
784
785/// Validates github https base invariants for this contract.
786fn validate_github_https_base(url: &Url, message: &'static str) -> Result<(), UrlFieldError> {
787    validate_https_url(url, message)?;
788    if url.host_str() != Some("github.com")
789        || url.port().is_some()
790        || !url.username().is_empty()
791        || url.password().is_some()
792    {
793        return Err(UrlFieldError::new(message));
794    }
795    Ok(())
796}
797
798/// Validates https url invariants for this contract.
799fn validate_https_url(url: &Url, message: &'static str) -> Result<(), UrlFieldError> {
800    if url.scheme() != "https" || url.cannot_be_a_base() || url.host_str().is_none() {
801        return Err(UrlFieldError::new(message));
802    }
803    if url.query().is_some() || url.fragment().is_some() {
804        return Err(UrlFieldError::new(message));
805    }
806    Ok(())
807}
808
809/// Handles github path segments for this module.
810fn github_path_segments(url: &Url, message: &'static str) -> Result<Vec<String>, UrlFieldError> {
811    let Some(segments) = url.path_segments() else {
812        return Err(UrlFieldError::new(message));
813    };
814    let segments: Vec<String> = segments.map(ToString::to_string).collect();
815    if segments.iter().any(|segment| segment.is_empty()) {
816        return Err(UrlFieldError::new(message));
817    }
818    Ok(segments)
819}
820
821/// Validates github path segment invariants for this contract.
822fn validate_github_path_segment(value: &str, message: &'static str) -> Result<(), UrlFieldError> {
823    if value.is_empty()
824        || value == ".git"
825        || !value
826            .bytes()
827            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
828    {
829        return Err(UrlFieldError::new(message));
830    }
831    Ok(())
832}
833
834/// Handles reject whitespace or control for this module.
835fn reject_whitespace_or_control(value: &str, message: &'static str) -> Result<(), UrlFieldError> {
836    if value.is_empty() || value.chars().any(|c| c.is_whitespace() || c.is_control()) {
837        Err(UrlFieldError::new(message))
838    } else {
839        Ok(())
840    }
841}
842
843#[cfg(test)]
844mod tests {
845    use super::{
846        ExternalDataUrl, GithubApiUserUrl, GithubAppAuthorizeUrl, GithubAppRedirectUrl,
847        GithubAppTokenUrl, GithubPullRequestUrl, GithubRepoRemote, MoltbookPostUrl,
848        MoltbookSubmoltUrl,
849    };
850
851    /// Verifies that parses github repo remotes.
852    #[test]
853    fn parses_github_repo_remotes() {
854        let https =
855            GithubRepoRemote::try_new("https://github.com/Agentics-Reifying/Agentics-Challenges")
856                .expect("HTTPS remote is valid");
857        let https_with_suffix = GithubRepoRemote::try_new(
858            "https://github.com/agentics-reifying/agentics-challenges.git",
859        )
860        .expect("HTTPS .git remote is valid");
861        let ssh =
862            GithubRepoRemote::try_new("git@github.com:agentics-reifying/agentics-challenges.git")
863                .expect("SSH remote is valid");
864
865        assert_eq!(https.repository_key(), https_with_suffix.repository_key());
866        assert_eq!(https.repository_key(), ssh.repository_key());
867        assert_eq!(
868            https.repository_key().as_str(),
869            "agentics-reifying/agentics-challenges"
870        );
871        assert!(GithubRepoRemote::try_new("http://github.com/owner/repo").is_err());
872        assert!(GithubRepoRemote::try_new("https://example.com/owner/repo").is_err());
873        assert!(GithubRepoRemote::try_new("git@github.com:owner/repo").is_err());
874    }
875
876    /// Verifies that parses github pull request urls.
877    #[test]
878    fn parses_github_pull_request_urls() {
879        assert!(
880            GithubPullRequestUrl::try_new(
881                "https://github.com/agentics-reifying/agentics-challenges/pull/7",
882            )
883            .is_ok()
884        );
885        assert!(
886            GithubPullRequestUrl::try_new(
887                "git@github.com:agentics-reifying/agentics-challenges.git",
888            )
889            .is_err()
890        );
891        assert!(GithubPullRequestUrl::try_new("https://github.com/owner/repo/issues/7").is_err());
892    }
893
894    /// Verifies that parses challenge external urls.
895    #[test]
896    fn parses_challenge_external_urls() {
897        assert!(ExternalDataUrl::try_new("https://example.com/data.bin").is_ok());
898        assert!(ExternalDataUrl::try_new("http://example.com/data.bin").is_err());
899        assert!(ExternalDataUrl::try_new("https://example.com/data.bin#section").is_err());
900    }
901
902    /// Verifies GitHub App URLs reject embedded credentials.
903    #[test]
904    fn rejects_github_app_url_userinfo() {
905        assert!(GithubAppAuthorizeUrl::try_new("https://github.com/login/oauth/authorize").is_ok());
906        assert!(GithubAppTokenUrl::try_new("https://github.com/login/oauth/access_token").is_ok());
907        assert!(GithubApiUserUrl::try_new("https://api.github.com/user").is_ok());
908        assert!(
909            GithubAppRedirectUrl::try_new("http://127.0.0.1:3001/auth/github/callback").is_ok()
910        );
911
912        assert!(
913            GithubAppAuthorizeUrl::try_new("https://user:pass@github.com/login/oauth/authorize")
914                .is_err()
915        );
916        assert!(
917            GithubAppTokenUrl::try_new("https://user:pass@github.com/login/oauth/access_token")
918                .is_err()
919        );
920        assert!(GithubApiUserUrl::try_new("https://user:pass@api.github.com/user").is_err());
921        assert!(
922            GithubAppRedirectUrl::try_new("http://user:pass@127.0.0.1:3001/auth/github/callback")
923                .is_err()
924        );
925    }
926
927    /// Verifies Moltbook URL wrappers accept only the www Moltbook contracts.
928    #[test]
929    fn parses_moltbook_urls() {
930        let submolt = MoltbookSubmoltUrl::try_new("https://www.moltbook.com/m/agentics-platform")
931            .expect("canonical Submolt URL should parse");
932        assert_eq!(
933            submolt
934                .submolt_name()
935                .expect("Submolt name should parse")
936                .as_str(),
937            "agentics-platform"
938        );
939        assert!(MoltbookPostUrl::try_new("https://www.moltbook.com/post/abc-123_X").is_ok());
940
941        for value in [
942            "https://moltbook.com/m/agentics-platform",
943            "https://www.moltbook.com/m/Agentics-Platform",
944            "https://www.moltbook.com/submolts/agentics-platform",
945            "https://www.moltbook.com/m/agentics-platform?x=1",
946            "https://example.com/m/agentics-platform",
947        ] {
948            assert!(MoltbookSubmoltUrl::try_new(value).is_err());
949        }
950
951        for value in [
952            "https://moltbook.com/post/abc-123",
953            "https://www.moltbook.com/posts/abc-123",
954            "https://www.moltbook.com/post/abc-123/comments",
955            "https://www.moltbook.com/post/abc.123",
956            "https://example.com/post/abc-123",
957        ] {
958            assert!(MoltbookPostUrl::try_new(value).is_err());
959        }
960    }
961}