Skip to main content

appcore_update/
peer.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: peer.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/09/24 00:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/09/24 00:00:00 by dnettoRaw
8//      ###########      S: 1.0.3-rc
9// =============================================================================
10
11//! Update payloads carried inside authenticated Peer RPC V2 streams.
12
13use crate::{
14    ArtifactAuthenticityVerifier, ArtifactDescriptor, ArtifactTarget, UpdateError, UpdateResult,
15};
16use appcore_contracts::{ApplicationId, BuildId};
17use serde::{Deserialize, Serialize};
18
19/// Capability used to negotiate availability of one immutable artifact.
20pub const UPDATE_OFFER_CAPABILITY: &str = "appcore.update.offer";
21/// Capability used to request one bounded artifact byte range.
22pub const UPDATE_CHUNK_CAPABILITY: &str = "appcore.update.chunk";
23/// Maximum serialized metadata accepted by the update payload contracts.
24pub const UPDATE_PEER_METADATA_MAX_BYTES: usize = 16 * 1024;
25
26/// Typed status returned by an update peer before byte streaming begins.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum ArtifactPeerStatusV2 {
30    /// The peer can serve the requested artifact.
31    Accepted,
32    /// Transport or Gateway authorization rejected the request.
33    Unauthorized,
34    /// The peer is temporarily unable to serve the artifact.
35    Unavailable,
36    /// The requested catalog state is no longer current.
37    Stale,
38    /// Identity, protocol or target is incompatible.
39    Incompatible,
40    /// The peer found invalid or corrupted artifact state.
41    Corrupted,
42}
43
44/// Path-free offer metadata sent through a Peer RPC V2 request stream.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct ArtifactOfferRequestV2 {
47    /// Application identity selected by the caller.
48    pub application_id: ApplicationId,
49    /// Immutable build identity selected by the caller.
50    pub build_id: BuildId,
51    /// Platform target selected by the caller.
52    pub target: ArtifactTarget,
53    /// Artifact digest expected by the caller.
54    pub sha256: String,
55    /// Exact artifact size expected by the caller.
56    pub size_bytes: u64,
57    /// Application/runtime protocol expected by the caller.
58    pub protocol_version: String,
59}
60
61/// Response metadata for an artifact offer.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct ArtifactOfferResponseV2 {
64    /// Peer decision before opening a byte stream.
65    pub status: ArtifactPeerStatusV2,
66    /// Digest repeated by the peer.
67    pub sha256: String,
68    /// Size repeated by the peer.
69    pub size_bytes: u64,
70    /// Maximum decoded chunk accepted by the peer.
71    pub max_chunk_bytes: u32,
72    /// Application/runtime protocol repeated by the peer.
73    pub protocol_version: String,
74}
75
76/// Requests the latest signed release compatible with a Runtime host.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct LatestCompatibleOfferRequestV2 {
79    /// Application identity selected by the caller.
80    pub application_id: ApplicationId,
81    /// Platform target selected by the caller.
82    pub target: ArtifactTarget,
83    /// Requested update channel.
84    pub channel: String,
85    /// Currently installed semantic version.
86    pub current_version: String,
87    /// Runtime semantic version available on the host.
88    pub runtime_version: String,
89    /// Application/runtime protocol available on the host.
90    pub protocol_version: String,
91}
92
93/// Latest compatible signed descriptor selected by an update peer.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct LatestCompatibleOfferResponseV2 {
96    /// Peer decision before byte streaming begins.
97    pub status: ArtifactPeerStatusV2,
98    /// Signed descriptor selected by the peer, when accepted.
99    pub descriptor: Option<ArtifactDescriptor>,
100}
101
102/// Path-free bounded byte-range request carried by a Peer RPC V2 stream.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct ArtifactChunkRequestV2 {
105    /// Artifact digest addressed by this request.
106    pub sha256: String,
107    /// Zero-based byte offset.
108    pub offset: u64,
109    /// Requested decoded byte length.
110    pub len: u32,
111}
112
113/// Metadata repeated beside the decoded bytes in a Peer RPC V2 response stream.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct ArtifactChunkResponseV2 {
116    /// Artifact digest repeated by the peer.
117    pub sha256: String,
118    /// Offset repeated by the peer.
119    pub offset: u64,
120    /// Decoded byte length repeated by the peer.
121    pub len: u32,
122    /// SHA-256 of the decoded response chunk.
123    pub chunk_sha256: String,
124    /// Complete artifact size repeated by the peer.
125    pub total_size_bytes: u64,
126}
127
128impl ArtifactOfferRequestV2 {
129    /// Validates bounded offer metadata before it enters Peer RPC.
130    pub fn validate(&self) -> UpdateResult<()> {
131        validate_digest(&self.sha256)?;
132        validate_protocol(&self.protocol_version)?;
133        if self.size_bytes == 0 {
134            return Err(UpdateError::InvalidArtifact(
135                "peer offer size_bytes must be greater than zero".to_string(),
136            ));
137        }
138        validate_metadata_size(self)
139    }
140}
141
142impl LatestCompatibleOfferRequestV2 {
143    /// Validates bounded compatibility-selection metadata.
144    pub fn validate(&self) -> UpdateResult<()> {
145        validate_target(&self.target)?;
146        validate_text("channel", &self.channel, 64)?;
147        validate_version("current_version", &self.current_version)?;
148        validate_version("runtime_version", &self.runtime_version)?;
149        validate_protocol(&self.protocol_version)?;
150        validate_metadata_size(self)
151    }
152}
153
154impl LatestCompatibleOfferResponseV2 {
155    /// Validates the selected descriptor against the caller's compatibility query.
156    pub fn validate_against(&self, request: &LatestCompatibleOfferRequestV2) -> UpdateResult<()> {
157        request.validate()?;
158        match (&self.status, &self.descriptor) {
159            (ArtifactPeerStatusV2::Accepted, Some(descriptor)) => {
160                descriptor.validate()?;
161                let target = descriptor.target().ok_or_else(|| {
162                    UpdateError::Incompatible(
163                        "latest compatible offer must include a platform target".to_string(),
164                    )
165                })?;
166                if descriptor.application_id() != &request.application_id
167                    || descriptor.channel() != request.channel
168                    || target != &request.target
169                {
170                    return Err(UpdateError::Incompatible(
171                        "latest compatible offer does not match the request".to_string(),
172                    ));
173                }
174                let offered_version = semver::Version::parse(descriptor.application_version())
175                    .map_err(|error| {
176                        UpdateError::InvalidArtifact(format!("invalid offered version: {error}"))
177                    })?;
178                let current_version =
179                    semver::Version::parse(&request.current_version).map_err(|error| {
180                        UpdateError::Incompatible(format!("invalid installed version: {error}"))
181                    })?;
182                if offered_version <= current_version {
183                    return Err(UpdateError::Incompatible(
184                        "latest compatible offer is not newer than the installed version"
185                            .to_string(),
186                    ));
187                }
188                descriptor
189                    .ensure_compatible(&request.runtime_version, &request.protocol_version)?;
190            }
191            (ArtifactPeerStatusV2::Accepted, None) => {
192                return Err(UpdateError::Transfer(
193                    "accepted latest compatible offer has no descriptor".to_string(),
194                ));
195            }
196            (_, Some(_)) => {
197                return Err(UpdateError::Transfer(
198                    "non-accepted latest compatible offer must not include a descriptor"
199                        .to_string(),
200                ));
201            }
202            (_, None) => {}
203        }
204        validate_metadata_size(self)
205    }
206
207    /// Applies the caller's local authenticity and trust policy.
208    pub fn verify_descriptor(
209        &self,
210        verifier: &dyn ArtifactAuthenticityVerifier,
211    ) -> UpdateResult<()> {
212        if let Some(descriptor) = &self.descriptor {
213            verifier.verify(descriptor)?;
214        }
215        Ok(())
216    }
217}
218
219impl ArtifactOfferResponseV2 {
220    /// Validates that an offer response repeats the caller's identity exactly.
221    pub fn validate_against(&self, request: &ArtifactOfferRequestV2) -> UpdateResult<()> {
222        validate_digest(&self.sha256)?;
223        validate_protocol(&self.protocol_version)?;
224        if self.status == ArtifactPeerStatusV2::Accepted
225            && (self.sha256 != request.sha256
226                || self.size_bytes != request.size_bytes
227                || self.protocol_version != request.protocol_version
228                || self.max_chunk_bytes == 0)
229        {
230            return Err(UpdateError::Transfer(
231                "peer offer response does not repeat the requested artifact identity".to_string(),
232            ));
233        }
234        validate_metadata_size(self)
235    }
236}
237
238impl ArtifactChunkRequestV2 {
239    /// Validates a range request against the offered artifact and chunk limit.
240    pub fn validate_against(
241        &self,
242        offer: &ArtifactOfferRequestV2,
243        max_chunk_bytes: u32,
244    ) -> UpdateResult<()> {
245        validate_digest(&self.sha256)?;
246        if self.sha256 != offer.sha256
247            || self.len == 0
248            || self.len > max_chunk_bytes
249            || self.offset >= offer.size_bytes
250            || self.offset.saturating_add(self.len as u64) > offer.size_bytes
251        {
252            return Err(UpdateError::Transfer(
253                "peer chunk request is outside the offered artifact bounds".to_string(),
254            ));
255        }
256        validate_metadata_size(self)
257    }
258}
259
260impl ArtifactChunkResponseV2 {
261    /// Validates response metadata before accepting the decoded stream bytes.
262    pub fn validate_against(
263        &self,
264        request: &ArtifactChunkRequestV2,
265        total_size_bytes: u64,
266    ) -> UpdateResult<()> {
267        validate_digest(&self.sha256)?;
268        validate_digest(&self.chunk_sha256)?;
269        if self.sha256 != request.sha256
270            || self.offset != request.offset
271            || self.len == 0
272            || self.len > request.len
273            || self.total_size_bytes != total_size_bytes
274            || self.offset.saturating_add(self.len as u64) > total_size_bytes
275        {
276            return Err(UpdateError::Transfer(
277                "peer chunk response does not repeat the requested bounds".to_string(),
278            ));
279        }
280        validate_metadata_size(self)
281    }
282}
283
284fn validate_digest(value: &str) -> UpdateResult<()> {
285    if value.len() != 64
286        || !value
287            .bytes()
288            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
289    {
290        return Err(UpdateError::Transfer(
291            "peer update digest must be lowercase SHA-256".to_string(),
292        ));
293    }
294    Ok(())
295}
296
297fn validate_protocol(value: &str) -> UpdateResult<()> {
298    if value.trim().is_empty() || value.len() > 64 || value.chars().any(char::is_control) {
299        return Err(UpdateError::Incompatible(
300            "peer update protocol version is invalid".to_string(),
301        ));
302    }
303    Ok(())
304}
305
306fn validate_version(name: &str, value: &str) -> UpdateResult<()> {
307    validate_text(name, value, 64)?;
308    semver::Version::parse(value)
309        .map(|_| ())
310        .map_err(|error| UpdateError::Incompatible(format!("{name} is invalid: {error}")))
311}
312
313fn validate_text(name: &str, value: &str, max: usize) -> UpdateResult<()> {
314    if value.trim().is_empty() || value.len() > max || value.chars().any(char::is_control) {
315        return Err(UpdateError::Incompatible(format!(
316            "peer update {name} is invalid"
317        )));
318    }
319    Ok(())
320}
321
322fn validate_target(target: &ArtifactTarget) -> UpdateResult<()> {
323    for (name, value) in [
324        ("target.os", target.os()),
325        ("target.architecture", target.architecture()),
326        ("target.format", target.format()),
327    ] {
328        validate_text(name, value, 64)?;
329    }
330    Ok(())
331}
332
333fn validate_metadata_size<T: Serialize>(value: &T) -> UpdateResult<()> {
334    let size = serde_json::to_vec(value)
335        .map_err(|error| UpdateError::Transfer(error.to_string()))?
336        .len();
337    if size > UPDATE_PEER_METADATA_MAX_BYTES {
338        return Err(UpdateError::Transfer(
339            "peer update metadata exceeds configured limit".to_string(),
340        ));
341    }
342    Ok(())
343}