Skip to main content

appcore_update/
artifact.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: artifact.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/07/22 15:41:18 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/07/23 13:45:20 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Defines bounded artifact contracts and behavior for this crate.
12
13use crate::{UpdateError, UpdateResult};
14use appcore_contracts::{ApplicationId, BuildId};
15use semver::{Version, VersionReq};
16use serde::{Deserialize, Serialize};
17
18/// Immutable application artifact published by an update provider.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct ArtifactDescriptor {
21    application_id: ApplicationId,
22    application_version: String,
23    build_id: BuildId,
24    channel: String,
25    runtime_requirement: String,
26    protocol_version: String,
27    artifact_reference: String,
28    sha256: String,
29    size_bytes: u64,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    signing_key_id: Option<String>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    ed25519_signature: Option<String>,
34}
35
36impl ArtifactDescriptor {
37    /// Creates and validates an immutable artifact descriptor.
38    #[allow(clippy::too_many_arguments)]
39    pub fn new(
40        application_id: ApplicationId,
41        application_version: impl Into<String>,
42        build_id: BuildId,
43        channel: impl Into<String>,
44        runtime_requirement: impl Into<String>,
45        protocol_version: impl Into<String>,
46        artifact_reference: impl Into<String>,
47        sha256: impl Into<String>,
48        size_bytes: u64,
49    ) -> UpdateResult<Self> {
50        let descriptor = Self {
51            application_id,
52            application_version: application_version.into(),
53            build_id,
54            channel: channel.into(),
55            runtime_requirement: runtime_requirement.into(),
56            protocol_version: protocol_version.into(),
57            artifact_reference: artifact_reference.into(),
58            sha256: sha256.into(),
59            size_bytes,
60            signing_key_id: None,
61            ed25519_signature: None,
62        };
63        descriptor.validate()?;
64        Ok(descriptor)
65    }
66
67    /// Adds the Ed25519 signing key identity and detached signature.
68    pub fn with_ed25519_signature(
69        mut self,
70        signing_key_id: impl Into<String>,
71        signature: impl Into<String>,
72    ) -> UpdateResult<Self> {
73        self.signing_key_id = Some(signing_key_id.into());
74        self.ed25519_signature = Some(signature.into());
75        self.validate()?;
76        Ok(self)
77    }
78
79    /// Returns the application identity.
80    pub fn application_id(&self) -> &ApplicationId {
81        &self.application_id
82    }
83
84    /// Returns the semantic application version.
85    pub fn application_version(&self) -> &str {
86        &self.application_version
87    }
88
89    /// Returns the immutable build identity.
90    pub fn build_id(&self) -> &BuildId {
91        &self.build_id
92    }
93
94    /// Returns the publication channel.
95    pub fn channel(&self) -> &str {
96        &self.channel
97    }
98
99    /// Returns the runtime semantic-version requirement.
100    pub fn runtime_requirement(&self) -> &str {
101        &self.runtime_requirement
102    }
103
104    /// Returns the required distributed protocol version.
105    pub fn protocol_version(&self) -> &str {
106        &self.protocol_version
107    }
108
109    /// Returns the opaque provider reference used to fetch bytes.
110    pub fn artifact_reference(&self) -> &str {
111        &self.artifact_reference
112    }
113
114    /// Returns the lowercase SHA-256 checksum.
115    pub fn sha256(&self) -> &str {
116        &self.sha256
117    }
118
119    /// Returns the declared artifact size.
120    pub fn size_bytes(&self) -> u64 {
121        self.size_bytes
122    }
123
124    /// Returns the configured signing key identity.
125    pub fn signing_key_id(&self) -> Option<&str> {
126        self.signing_key_id.as_deref()
127    }
128
129    /// Returns the lowercase hexadecimal Ed25519 signature.
130    pub fn ed25519_signature(&self) -> Option<&str> {
131        self.ed25519_signature.as_deref()
132    }
133
134    /// Checks runtime and protocol compatibility.
135    pub fn ensure_compatible(
136        &self,
137        runtime_version: &str,
138        protocol_version: &str,
139    ) -> UpdateResult<()> {
140        let runtime = Version::parse(runtime_version).map_err(|error| {
141            UpdateError::Incompatible(format!("invalid runtime version: {error}"))
142        })?;
143        let requirement = VersionReq::parse(&self.runtime_requirement).map_err(|error| {
144            UpdateError::InvalidArtifact(format!("invalid runtime requirement: {error}"))
145        })?;
146        if !requirement.matches(&runtime) {
147            return Err(UpdateError::Incompatible(format!(
148                "runtime {runtime} does not satisfy {}",
149                self.runtime_requirement
150            )));
151        }
152        if self.protocol_version != protocol_version {
153            return Err(UpdateError::Incompatible(format!(
154                "protocol {} is required, host provides {protocol_version}",
155                self.protocol_version
156            )));
157        }
158        Ok(())
159    }
160
161    pub(crate) fn validate(&self) -> UpdateResult<()> {
162        for (name, value, max) in [
163            ("application_version", self.application_version.as_str(), 64),
164            ("channel", self.channel.as_str(), 64),
165            (
166                "runtime_requirement",
167                self.runtime_requirement.as_str(),
168                128,
169            ),
170            ("protocol_version", self.protocol_version.as_str(), 64),
171            (
172                "artifact_reference",
173                self.artifact_reference.as_str(),
174                2_048,
175            ),
176        ] {
177            if value.trim().is_empty() || value.len() > max || value.chars().any(char::is_control) {
178                return Err(UpdateError::InvalidArtifact(format!(
179                    "{name} is empty, too long or contains control characters"
180                )));
181            }
182        }
183        Version::parse(&self.application_version).map_err(|error| {
184            UpdateError::InvalidArtifact(format!("invalid application version: {error}"))
185        })?;
186        VersionReq::parse(&self.runtime_requirement).map_err(|error| {
187            UpdateError::InvalidArtifact(format!("invalid runtime requirement: {error}"))
188        })?;
189        if self.size_bytes == 0 {
190            return Err(UpdateError::InvalidArtifact(
191                "size_bytes must be greater than zero".to_string(),
192            ));
193        }
194        if self.sha256.len() != 64
195            || !self
196                .sha256
197                .bytes()
198                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
199        {
200            return Err(UpdateError::InvalidArtifact(
201                "sha256 must be 64 lowercase hexadecimal characters".to_string(),
202            ));
203        }
204        match (&self.signing_key_id, &self.ed25519_signature) {
205            (None, None) => {}
206            (Some(key_id), Some(signature)) => {
207                if key_id.trim().is_empty()
208                    || key_id.len() > 128
209                    || key_id.chars().any(char::is_control)
210                {
211                    return Err(UpdateError::InvalidArtifact(
212                        "signing_key_id is empty, too long or contains control characters"
213                            .to_string(),
214                    ));
215                }
216                if signature.len() != 128
217                    || !signature
218                        .bytes()
219                        .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
220                {
221                    return Err(UpdateError::InvalidArtifact(
222                        "ed25519_signature must be 128 lowercase hexadecimal characters"
223                            .to_string(),
224                    ));
225                }
226            }
227            _ => {
228                return Err(UpdateError::InvalidArtifact(
229                    "signing_key_id and ed25519_signature must be provided together".to_string(),
230                ));
231            }
232        }
233        Ok(())
234    }
235}