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/// Platform and packaging target for one published artifact.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct ArtifactTarget {
21    os: String,
22    architecture: String,
23    format: String,
24}
25
26impl ArtifactTarget {
27    /// Creates a validated platform target.
28    pub fn new(
29        os: impl Into<String>,
30        architecture: impl Into<String>,
31        format: impl Into<String>,
32    ) -> UpdateResult<Self> {
33        let target = Self {
34            os: os.into(),
35            architecture: architecture.into(),
36            format: format.into(),
37        };
38        target.validate()?;
39        Ok(target)
40    }
41
42    /// Returns the operating system identifier.
43    pub fn os(&self) -> &str {
44        &self.os
45    }
46
47    /// Returns the CPU architecture identifier.
48    pub fn architecture(&self) -> &str {
49        &self.architecture
50    }
51
52    /// Returns the host installation format identifier.
53    pub fn format(&self) -> &str {
54        &self.format
55    }
56
57    fn validate(&self) -> UpdateResult<()> {
58        for (name, value) in [
59            ("target.os", self.os.as_str()),
60            ("target.architecture", self.architecture.as_str()),
61            ("target.format", self.format.as_str()),
62        ] {
63            if value.trim().is_empty() || value.len() > 64 || value.chars().any(char::is_control) {
64                return Err(UpdateError::InvalidArtifact(format!(
65                    "{name} is empty, too long or contains control characters"
66                )));
67            }
68        }
69        Ok(())
70    }
71}
72
73/// Immutable application artifact published by an update provider.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct ArtifactDescriptor {
76    application_id: ApplicationId,
77    application_version: String,
78    build_id: BuildId,
79    channel: String,
80    runtime_requirement: String,
81    protocol_version: String,
82    artifact_reference: String,
83    sha256: String,
84    size_bytes: u64,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    signing_key_id: Option<String>,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    ed25519_signature: Option<String>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    target: Option<ArtifactTarget>,
91}
92
93impl ArtifactDescriptor {
94    /// Creates and validates an immutable artifact descriptor.
95    #[allow(clippy::too_many_arguments)]
96    pub fn new(
97        application_id: ApplicationId,
98        application_version: impl Into<String>,
99        build_id: BuildId,
100        channel: impl Into<String>,
101        runtime_requirement: impl Into<String>,
102        protocol_version: impl Into<String>,
103        artifact_reference: impl Into<String>,
104        sha256: impl Into<String>,
105        size_bytes: u64,
106    ) -> UpdateResult<Self> {
107        let descriptor = Self {
108            application_id,
109            application_version: application_version.into(),
110            build_id,
111            channel: channel.into(),
112            runtime_requirement: runtime_requirement.into(),
113            protocol_version: protocol_version.into(),
114            artifact_reference: artifact_reference.into(),
115            sha256: sha256.into(),
116            size_bytes,
117            signing_key_id: None,
118            ed25519_signature: None,
119            target: None,
120        };
121        descriptor.validate()?;
122        Ok(descriptor)
123    }
124
125    /// Adds the Ed25519 signing key identity and detached signature.
126    pub fn with_ed25519_signature(
127        mut self,
128        signing_key_id: impl Into<String>,
129        signature: impl Into<String>,
130    ) -> UpdateResult<Self> {
131        self.signing_key_id = Some(signing_key_id.into());
132        self.ed25519_signature = Some(signature.into());
133        self.validate()?;
134        Ok(self)
135    }
136
137    /// Adds a platform target before signing a catalog entry.
138    pub fn with_target(mut self, target: ArtifactTarget) -> UpdateResult<Self> {
139        if self.signing_key_id.is_some() || self.ed25519_signature.is_some() {
140            return Err(UpdateError::InvalidArtifact(
141                "target must be set before the descriptor is signed".to_string(),
142            ));
143        }
144        target.validate()?;
145        self.target = Some(target);
146        Ok(self)
147    }
148
149    /// Returns the application identity.
150    pub fn application_id(&self) -> &ApplicationId {
151        &self.application_id
152    }
153
154    /// Returns the semantic application version.
155    pub fn application_version(&self) -> &str {
156        &self.application_version
157    }
158
159    /// Returns the immutable build identity.
160    pub fn build_id(&self) -> &BuildId {
161        &self.build_id
162    }
163
164    /// Returns the publication channel.
165    pub fn channel(&self) -> &str {
166        &self.channel
167    }
168
169    /// Returns the runtime semantic-version requirement.
170    pub fn runtime_requirement(&self) -> &str {
171        &self.runtime_requirement
172    }
173
174    /// Returns the required distributed protocol version.
175    pub fn protocol_version(&self) -> &str {
176        &self.protocol_version
177    }
178
179    /// Returns the opaque provider reference used to fetch bytes.
180    pub fn artifact_reference(&self) -> &str {
181        &self.artifact_reference
182    }
183
184    /// Returns the lowercase SHA-256 checksum.
185    pub fn sha256(&self) -> &str {
186        &self.sha256
187    }
188
189    /// Returns the declared artifact size.
190    pub fn size_bytes(&self) -> u64 {
191        self.size_bytes
192    }
193
194    /// Returns the configured signing key identity.
195    pub fn signing_key_id(&self) -> Option<&str> {
196        self.signing_key_id.as_deref()
197    }
198
199    /// Returns the lowercase hexadecimal Ed25519 signature.
200    pub fn ed25519_signature(&self) -> Option<&str> {
201        self.ed25519_signature.as_deref()
202    }
203
204    /// Returns the platform target, when the descriptor is catalog-enabled.
205    pub fn target(&self) -> Option<&ArtifactTarget> {
206        self.target.as_ref()
207    }
208
209    /// Checks runtime and protocol compatibility.
210    pub fn ensure_compatible(
211        &self,
212        runtime_version: &str,
213        protocol_version: &str,
214    ) -> UpdateResult<()> {
215        let runtime = Version::parse(runtime_version).map_err(|error| {
216            UpdateError::Incompatible(format!("invalid runtime version: {error}"))
217        })?;
218        let requirement = VersionReq::parse(&self.runtime_requirement).map_err(|error| {
219            UpdateError::InvalidArtifact(format!("invalid runtime requirement: {error}"))
220        })?;
221        if !requirement.matches(&runtime) {
222            return Err(UpdateError::Incompatible(format!(
223                "runtime {runtime} does not satisfy {}",
224                self.runtime_requirement
225            )));
226        }
227        if self.protocol_version != protocol_version {
228            return Err(UpdateError::Incompatible(format!(
229                "protocol {} is required, host provides {protocol_version}",
230                self.protocol_version
231            )));
232        }
233        Ok(())
234    }
235
236    /// Validates the descriptor without fetching or activating its artifact.
237    pub fn validate(&self) -> UpdateResult<()> {
238        for (name, value, max) in [
239            ("application_version", self.application_version.as_str(), 64),
240            ("channel", self.channel.as_str(), 64),
241            (
242                "runtime_requirement",
243                self.runtime_requirement.as_str(),
244                128,
245            ),
246            ("protocol_version", self.protocol_version.as_str(), 64),
247            (
248                "artifact_reference",
249                self.artifact_reference.as_str(),
250                2_048,
251            ),
252        ] {
253            if value.trim().is_empty() || value.len() > max || value.chars().any(char::is_control) {
254                return Err(UpdateError::InvalidArtifact(format!(
255                    "{name} is empty, too long or contains control characters"
256                )));
257            }
258        }
259        Version::parse(&self.application_version).map_err(|error| {
260            UpdateError::InvalidArtifact(format!("invalid application version: {error}"))
261        })?;
262        VersionReq::parse(&self.runtime_requirement).map_err(|error| {
263            UpdateError::InvalidArtifact(format!("invalid runtime requirement: {error}"))
264        })?;
265        if self.size_bytes == 0 {
266            return Err(UpdateError::InvalidArtifact(
267                "size_bytes must be greater than zero".to_string(),
268            ));
269        }
270        if self.sha256.len() != 64
271            || !self
272                .sha256
273                .bytes()
274                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
275        {
276            return Err(UpdateError::InvalidArtifact(
277                "sha256 must be 64 lowercase hexadecimal characters".to_string(),
278            ));
279        }
280        match (&self.signing_key_id, &self.ed25519_signature) {
281            (None, None) => {}
282            (Some(key_id), Some(signature)) => {
283                if key_id.trim().is_empty()
284                    || key_id.len() > 128
285                    || key_id.chars().any(char::is_control)
286                {
287                    return Err(UpdateError::InvalidArtifact(
288                        "signing_key_id is empty, too long or contains control characters"
289                            .to_string(),
290                    ));
291                }
292                if signature.len() != 128
293                    || !signature
294                        .bytes()
295                        .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
296                {
297                    return Err(UpdateError::InvalidArtifact(
298                        "ed25519_signature must be 128 lowercase hexadecimal characters"
299                            .to_string(),
300                    ));
301                }
302            }
303            _ => {
304                return Err(UpdateError::InvalidArtifact(
305                    "signing_key_id and ed25519_signature must be provided together".to_string(),
306                ));
307            }
308        }
309        if let Some(target) = &self.target {
310            target.validate()?;
311        }
312        Ok(())
313    }
314}