Skip to main content

appcore_contracts/
identifiers.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: identifiers.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/07/21 23:21:21 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/07/24 11:51:10 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Validated identifiers shared by manifests.
12
13use crate::{ContractError, ContractResult};
14use serde::{Deserialize, Deserializer, Serialize};
15use std::fmt::{Display, Formatter};
16use std::str::FromStr;
17
18pub(crate) fn validate_text(
19    field: &'static str,
20    value: &str,
21    max_bytes: usize,
22) -> ContractResult<()> {
23    if value.trim().is_empty() {
24        return Err(ContractError::Empty { field });
25    }
26    if value.len() > max_bytes {
27        return Err(ContractError::TooLong { field, max_bytes });
28    }
29    if value.chars().any(char::is_control) {
30        return Err(ContractError::InvalidValue {
31            field,
32            reason: "control characters are not allowed",
33        });
34    }
35    Ok(())
36}
37
38pub(crate) fn validate_identifier(field: &'static str, value: &str) -> ContractResult<()> {
39    validate_text(field, value, 128)?;
40    let mut chars = value.chars();
41    let Some(first) = chars.next() else {
42        return Err(ContractError::InvalidIdentifier { field });
43    };
44    if !first.is_ascii_alphanumeric()
45        || !value
46            .chars()
47            .all(|character| character.is_ascii_alphanumeric() || "-_.:+".contains(character))
48        || !value
49            .chars()
50            .last()
51            .is_some_and(|character| character.is_ascii_alphanumeric())
52    {
53        return Err(ContractError::InvalidIdentifier { field });
54    }
55    Ok(())
56}
57
58pub(crate) fn is_sensitive_key(value: &str) -> bool {
59    let normalized = value.to_ascii_lowercase();
60    [
61        "secret",
62        "password",
63        "passwd",
64        "token",
65        "private_key",
66        "credential",
67    ]
68    .iter()
69    .any(|fragment| normalized.contains(fragment))
70}
71
72pub(crate) fn looks_like_local_path(value: &str) -> bool {
73    value.starts_with('/')
74        || value.starts_with("./")
75        || value.starts_with("../")
76        || value.as_bytes().get(1) == Some(&b':')
77}
78
79pub(crate) fn looks_like_url(value: &str) -> bool {
80    value.contains("://")
81}
82
83macro_rules! define_identifier {
84    ($name:ident, $field:literal, $docs:literal) => {
85        #[doc = $docs]
86        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
87        #[serde(transparent)]
88        pub struct $name(String);
89
90        impl $name {
91            /// Creates a validated identifier.
92            pub fn new(value: impl Into<String>) -> ContractResult<Self> {
93                let value = value.into();
94                validate_identifier($field, &value)?;
95                Ok(Self(value))
96            }
97
98            /// Returns the identifier as a string slice.
99            pub fn as_str(&self) -> &str {
100                &self.0
101            }
102
103            /// Consumes the identifier and returns the owned string.
104            pub fn into_inner(self) -> String {
105                self.0
106            }
107        }
108
109        impl Display for $name {
110            fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
111                formatter.write_str(&self.0)
112            }
113        }
114
115        impl FromStr for $name {
116            type Err = ContractError;
117
118            fn from_str(value: &str) -> Result<Self, Self::Err> {
119                Self::new(value)
120            }
121        }
122
123        impl<'de> Deserialize<'de> for $name {
124            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
125            where
126                D: Deserializer<'de>,
127            {
128                let value = String::deserialize(deserializer)?;
129                Self::new(value).map_err(serde::de::Error::custom)
130            }
131        }
132    };
133}
134
135define_identifier!(
136    ApplicationId,
137    "application_id",
138    "Stable identity of an application."
139);
140define_identifier!(
141    ServiceId,
142    "service_id",
143    "Identity of an independently coordinated service."
144);
145define_identifier!(
146    CapabilityId,
147    "capability_id",
148    "Generic capability name resolved by the runtime."
149);
150define_identifier!(
151    ProviderId,
152    "provider_id",
153    "Identity of a deployment provider or adapter."
154);
155define_identifier!(
156    NodeId,
157    "node_id",
158    "Identity of the physical or virtual runtime node."
159);
160define_identifier!(
161    CoreId,
162    "core_id",
163    "Identity of one executable core hosted on a node."
164);
165define_identifier!(
166    InstallationId,
167    "installation_id",
168    "Identity of one application installation."
169);
170define_identifier!(ModuleId, "module_id", "Identity of an application module.");
171define_identifier!(JobId, "job_id", "Identity of a provider-owned runtime job.");
172define_identifier!(
173    FeatureId,
174    "feature_id",
175    "Identity of a declared runtime or application feature."
176);
177define_identifier!(
178    BuildId,
179    "build_id",
180    "Identity of an immutable runtime or application build."
181);
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn identifiers_reject_paths_and_whitespace() {
189        assert!(ApplicationId::new("app.example").is_ok());
190        assert!(ApplicationId::new("../app").is_err());
191        assert!(ApplicationId::new("app example").is_err());
192    }
193}