aster_forge_cloud_files_windows/
identity.rs

1//! Versioned mapping between `CloudItemKey` and CFAPI `FileIdentity` bytes.
2
3use std::{fmt, mem::size_of};
4
5use aster_forge_cloud_files_core::{
6    CloudItemId, CloudItemKey, CloudNamespaceId, CloudRootId, CloudScope,
7};
8
9use crate::{Result, WindowsCloudFilesError};
10
11/// Maximum `FileIdentity` size accepted by Windows Cloud Files.
12pub const CFAPI_FILE_IDENTITY_MAX_BYTES: usize = 4096;
13
14const MAGIC: &[u8; 4] = b"AFCF";
15const FORMAT_VERSION: u8 = 1;
16const HEADER_LEN: usize = MAGIC.len() + 1 + 3 * size_of::<u32>();
17
18/// Owned, validated CFAPI file identity for one exact Forge item key.
19#[derive(Clone, PartialEq, Eq, Hash)]
20pub struct WindowsFileIdentity(Vec<u8>);
21
22impl WindowsFileIdentity {
23    /// Encodes a scoped, path-independent Forge item key using the current versioned envelope.
24    /// # Errors
25    ///
26    /// Returns an error when validation fails or an underlying backend, store, or platform
27    /// operation fails.
28    pub fn encode(key: &CloudItemKey) -> Result<Self> {
29        let fields = [
30            key.scope().namespace_id().as_str().as_bytes(),
31            key.scope().root_id().as_str().as_bytes(),
32            key.item_id().as_str().as_bytes(),
33        ];
34        let payload_len = fields
35            .iter()
36            .try_fold(0usize, |total, field| total.checked_add(field.len()))
37            .ok_or(WindowsCloudFilesError::FileIdentityTooLarge {
38                actual: usize::MAX,
39                maximum: CFAPI_FILE_IDENTITY_MAX_BYTES,
40            })?;
41        let total_len = HEADER_LEN.checked_add(payload_len).ok_or(
42            WindowsCloudFilesError::FileIdentityTooLarge {
43                actual: usize::MAX,
44                maximum: CFAPI_FILE_IDENTITY_MAX_BYTES,
45            },
46        )?;
47        validate_size(total_len)?;
48
49        let mut encoded = Vec::with_capacity(total_len);
50        encoded.extend_from_slice(MAGIC);
51        encoded.push(FORMAT_VERSION);
52        for field in fields {
53            let len = u32::try_from(field.len()).map_err(|_| {
54                WindowsCloudFilesError::FileIdentityTooLarge {
55                    actual: field.len(),
56                    maximum: CFAPI_FILE_IDENTITY_MAX_BYTES,
57                }
58            })?;
59            encoded.extend_from_slice(&len.to_le_bytes());
60        }
61        for field in fields {
62            encoded.extend_from_slice(field);
63        }
64        Ok(Self(encoded))
65    }
66
67    /// Validates owned bytes and preserves the canonical identity envelope.
68    /// # Errors
69    ///
70    /// Returns an error when validation fails or an underlying backend, store, or platform
71    /// operation fails.
72    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
73        validate_size(bytes.len())?;
74        decode_bytes(&bytes)?;
75        Ok(Self(bytes))
76    }
77
78    /// Decodes the stable scoped Forge item key.
79    /// # Errors
80    ///
81    /// Returns an error when validation fails or an underlying backend, store, or platform
82    /// operation fails.
83    pub fn decode(&self) -> Result<CloudItemKey> {
84        decode_bytes(&self.0)
85    }
86
87    /// Returns the exact bytes supplied to CFAPI.
88    #[must_use]
89    pub fn as_bytes(&self) -> &[u8] {
90        &self.0
91    }
92
93    /// Returns the encoded byte length.
94    #[must_use]
95    pub fn len(&self) -> usize {
96        self.0.len()
97    }
98
99    /// Returns whether the encoded identity is empty.
100    #[must_use]
101    pub fn is_empty(&self) -> bool {
102        self.0.is_empty()
103    }
104
105    /// Consumes the identity and returns its exact bytes.
106    #[must_use]
107    pub fn into_bytes(self) -> Vec<u8> {
108        self.0
109    }
110}
111
112impl fmt::Debug for WindowsFileIdentity {
113    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
114        formatter
115            .debug_struct("WindowsFileIdentity")
116            .field("format_version", &FORMAT_VERSION)
117            .field("byte_len", &self.0.len())
118            .finish()
119    }
120}
121
122fn validate_size(actual: usize) -> Result<()> {
123    if actual > CFAPI_FILE_IDENTITY_MAX_BYTES {
124        return Err(WindowsCloudFilesError::FileIdentityTooLarge {
125            actual,
126            maximum: CFAPI_FILE_IDENTITY_MAX_BYTES,
127        });
128    }
129    Ok(())
130}
131
132fn decode_bytes(bytes: &[u8]) -> Result<CloudItemKey> {
133    if bytes.len() < HEADER_LEN {
134        return Err(invalid("identity envelope is truncated"));
135    }
136    if bytes.get(..MAGIC.len()) != Some(MAGIC.as_slice()) {
137        return Err(invalid("identity envelope magic does not match"));
138    }
139    if bytes[MAGIC.len()] != FORMAT_VERSION {
140        return Err(invalid("identity envelope version is unsupported"));
141    }
142
143    let lengths_start = MAGIC.len() + 1;
144    let mut lengths = [0usize; 3];
145    for (index, length) in lengths.iter_mut().enumerate() {
146        let start = lengths_start + index * size_of::<u32>();
147        let end = start + size_of::<u32>();
148        let raw: [u8; 4] = bytes[start..end]
149            .try_into()
150            .map_err(|_| invalid("identity length table is truncated"))?;
151        *length = usize::try_from(u32::from_le_bytes(raw))
152            .map_err(|_| invalid("identity field length cannot be represented"))?;
153    }
154
155    let expected_len = lengths.iter().try_fold(HEADER_LEN, |total, length| {
156        total
157            .checked_add(*length)
158            .ok_or_else(|| invalid("identity field lengths overflow"))
159    })?;
160    if expected_len != bytes.len() {
161        return Err(invalid("identity field lengths do not match the envelope"));
162    }
163
164    let mut offset = HEADER_LEN;
165    let mut fields = Vec::with_capacity(lengths.len());
166    for length in lengths {
167        let end = offset + length;
168        let value = std::str::from_utf8(&bytes[offset..end])
169            .map_err(|_| invalid("identity field is not UTF-8"))?;
170        fields.push(value.to_owned());
171        offset = end;
172    }
173
174    let mut fields = fields.into_iter();
175    let namespace = fields
176        .next()
177        .ok_or_else(|| invalid("identity namespace is missing"))?;
178    let root = fields
179        .next()
180        .ok_or_else(|| invalid("identity root is missing"))?;
181    let item = fields
182        .next()
183        .ok_or_else(|| invalid("identity item is missing"))?;
184    Ok(CloudItemKey::new(
185        CloudScope::new(CloudNamespaceId::new(namespace)?, CloudRootId::new(root)?),
186        CloudItemId::new(item)?,
187    ))
188}
189
190const fn invalid(reason: &'static str) -> WindowsCloudFilesError {
191    WindowsCloudFilesError::InvalidFileIdentity { reason }
192}