Skip to main content

stow_types/
identity.rs

1//! Newtype wrappers for the five-element artifact identity carried over the
2//! wire and persisted in D1.
3//!
4//! The canonical identity tuple is `(crate_name, version, features_json,
5//! target, rustc_version)`. Historically each was a bare `String` on the wire
6//! and the edge worker re-validated them per request via hand-rolled
7//! `validate_*` helpers. These newtypes move the validation into
8//! `serde::Deserialize`, so:
9//!
10//! * Every wire payload either parses successfully into a structured value or
11//!   produces a precise deserialize error at the protocol boundary.
12//! * Edge handlers receive types that are correct-by-construction.
13//! * Adding or renaming a constraint becomes a compile-time edit, not a
14//!   per-call-site audit.
15//!
16//! All wrappers serialize as the same primitive (string for most, JSON-encoded
17//! string for [`FeaturesJson`] and [`DependencyCMetadataJson`]) so the wire
18//! format is byte-for-byte identical to the previous stringly-typed shape.
19
20use std::ffi::OsStr;
21use std::fmt;
22use std::path::Path;
23use std::str::FromStr;
24
25use serde::{Deserialize, Deserializer, Serialize, Serializer};
26
27/// Errors produced while parsing wire identity values.
28#[derive(Debug, thiserror::Error)]
29pub enum IdentityError {
30    /// The crate name violates crates.io's allowed character / length rules.
31    #[error("invalid crate_name `{0}`: must be 1..=128 ASCII alphanumeric, `-` or `_`")]
32    InvalidCrateName(String),
33    /// The cargo `-C metadata` value is not a hex hash of length 1..=64.
34    #[error("invalid c_metadata `{0}`: must be 1..=64 ASCII hex digits")]
35    InvalidCMetadata(String),
36    /// The target triple has unexpected characters or length.
37    #[error("invalid target `{0}`: must be 1..=128 alphanumeric, `-` or `_`")]
38    InvalidTarget(String),
39    /// The crate version is not semver.
40    #[error("invalid version `{0}`: must be a semver release like 1.0.219")]
41    InvalidCrateVersion(String),
42    /// The rustc version string failed shape validation.
43    #[error("invalid rustc_version `{0}`: must be 1..=64 alphanumeric, `.`, `-`, or `_`")]
44    InvalidRustcVersion(String),
45    /// One feature name is empty, too long, or contains invalid characters.
46    #[error("invalid feature name `{0}`: must be 1..=128 alphanumeric, `-`, `_`, or `.`")]
47    InvalidFeatureName(String),
48    /// A feature list was not strictly sorted and deduplicated.
49    #[error("features must be strictly sorted and deduplicated")]
50    UnsortedFeatures,
51    /// An emit entry is empty, too long, or contains invalid characters.
52    #[error("invalid emit entry `{0}`: must be 1..=32 alphanumeric, `-`, or `_`")]
53    InvalidEmitEntry(String),
54    /// An emit list was not strictly sorted and deduplicated.
55    #[error("emit entries must be strictly sorted and deduplicated")]
56    UnsortedEmit,
57    /// Failed to parse a JSON wrapper string.
58    #[error("invalid JSON wrapper: {0}")]
59    InvalidJsonWrapper(String),
60    /// Dependency identities not sorted by `(crate_name, c_metadata)`.
61    #[error("dependency_c_metadata_json must be sorted by (crate_name, c_metadata)")]
62    UnsortedDependencyIdentities,
63}
64
65fn validate_crate_name(value: &str) -> Result<(), IdentityError> {
66    if value.is_empty()
67        || value.len() > 128
68        || !value
69            .chars()
70            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
71    {
72        return Err(IdentityError::InvalidCrateName(value.to_owned()));
73    }
74    Ok(())
75}
76
77fn validate_c_metadata(value: &str) -> Result<(), IdentityError> {
78    if value.is_empty() || value.len() > 64 || !value.chars().all(|ch| ch.is_ascii_hexdigit()) {
79        return Err(IdentityError::InvalidCMetadata(value.to_owned()));
80    }
81    Ok(())
82}
83
84fn validate_target(value: &str) -> Result<(), IdentityError> {
85    if value.is_empty()
86        || value.len() > 128
87        || !value
88            .chars()
89            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
90    {
91        return Err(IdentityError::InvalidTarget(value.to_owned()));
92    }
93    Ok(())
94}
95
96fn validate_rustc_version(value: &str) -> Result<(), IdentityError> {
97    if value.is_empty()
98        || value.len() > 64
99        || !value
100            .chars()
101            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_'))
102    {
103        return Err(IdentityError::InvalidRustcVersion(value.to_owned()));
104    }
105    Ok(())
106}
107
108fn validate_feature_name(value: &str) -> Result<(), IdentityError> {
109    if value.is_empty()
110        || value.len() > 128
111        || !value
112            .chars()
113            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
114    {
115        return Err(IdentityError::InvalidFeatureName(value.to_owned()));
116    }
117    Ok(())
118}
119
120fn validate_features_sorted(features: &[String]) -> Result<(), IdentityError> {
121    let mut previous: Option<&str> = None;
122    for feature in features {
123        validate_feature_name(feature)?;
124        if previous.is_some_and(|last| last >= feature.as_str()) {
125            return Err(IdentityError::UnsortedFeatures);
126        }
127        previous = Some(feature.as_str());
128    }
129    Ok(())
130}
131
132fn validate_emit_entry(value: &str) -> Result<(), IdentityError> {
133    if value.is_empty()
134        || value.len() > 32
135        || !value
136            .chars()
137            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
138    {
139        return Err(IdentityError::InvalidEmitEntry(value.to_owned()));
140    }
141    Ok(())
142}
143
144/// Validate that `emit` is strictly sorted, deduplicated, and each entry
145/// matches the rustc emit-mode shape.
146///
147/// # Errors
148/// Returns [`IdentityError::InvalidEmitEntry`] for a malformed entry, or
149/// [`IdentityError::UnsortedEmit`] when the list is not strictly increasing.
150pub fn validate_emit_sorted(emit: &[String]) -> Result<(), IdentityError> {
151    let mut previous: Option<&str> = None;
152    for entry in emit {
153        validate_emit_entry(entry)?;
154        if previous.is_some_and(|last| last >= entry.as_str()) {
155            return Err(IdentityError::UnsortedEmit);
156        }
157        previous = Some(entry.as_str());
158    }
159    Ok(())
160}
161
162macro_rules! string_newtype {
163    (
164        $(#[$meta:meta])*
165        $name:ident, $validate:ident, $error:ident
166    ) => {
167        $(#[$meta])*
168        #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, utoipa::ToSchema)]
169        pub struct $name(String);
170
171        impl $name {
172            /// Construct, validating the input.
173            ///
174            /// # Errors
175            /// Returns the [`IdentityError`] variant for this newtype's
176            /// validation rule when `value` violates it.
177            pub fn parse<S: Into<String>>(value: S) -> Result<Self, IdentityError> {
178                let value = value.into();
179                $validate(&value)?;
180                Ok(Self(value))
181            }
182
183            /// Borrow the underlying string.
184            pub fn as_str(&self) -> &str {
185                &self.0
186            }
187
188            /// Consume and return the inner `String`.
189            pub fn into_inner(self) -> String {
190                self.0
191            }
192        }
193
194        impl AsRef<str> for $name {
195            fn as_ref(&self) -> &str {
196                &self.0
197            }
198        }
199
200        impl AsRef<OsStr> for $name {
201            fn as_ref(&self) -> &OsStr {
202                self.0.as_ref()
203            }
204        }
205
206        impl AsRef<Path> for $name {
207            fn as_ref(&self) -> &Path {
208                self.0.as_ref()
209            }
210        }
211
212        impl PartialEq<str> for $name {
213            fn eq(&self, other: &str) -> bool {
214                self.0.as_str() == other
215            }
216        }
217
218        impl PartialEq<&str> for $name {
219            fn eq(&self, other: &&str) -> bool {
220                self.0.as_str() == *other
221            }
222        }
223
224        impl PartialEq<$name> for &str {
225            fn eq(&self, other: &$name) -> bool {
226                *self == other.0.as_str()
227            }
228        }
229
230        impl PartialEq<$name> for str {
231            fn eq(&self, other: &$name) -> bool {
232                self == other.0.as_str()
233            }
234        }
235
236        impl fmt::Display for $name {
237            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238                f.write_str(&self.0)
239            }
240        }
241
242        impl FromStr for $name {
243            type Err = IdentityError;
244            fn from_str(value: &str) -> Result<Self, Self::Err> {
245                Self::parse(value)
246            }
247        }
248
249        impl Serialize for $name {
250            fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
251                self.0.serialize(s)
252            }
253        }
254
255        impl<'de> Deserialize<'de> for $name {
256            fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
257                let raw = String::deserialize(d)?;
258                Self::parse(raw).map_err(serde::de::Error::custom)
259            }
260        }
261    };
262}
263
264string_newtype!(
265    /// A crates.io crate name. ASCII alphanumeric plus `-` and `_`, 1..=128 chars.
266    CrateName,
267    validate_crate_name,
268    InvalidCrateName
269);
270string_newtype!(
271    /// Cargo `-C metadata` value: hex digits, 1..=64 chars.
272    CMetadata,
273    validate_c_metadata,
274    InvalidCMetadata
275);
276string_newtype!(
277    /// A compilation target triple (free-form, validated for shape only).
278    TargetTriple,
279    validate_target,
280    InvalidTarget
281);
282string_newtype!(
283    /// Wire form of a rustc version string (e.g. `"1.83.0"`). Shape only.
284    WireRustcVersion,
285    validate_rustc_version,
286    InvalidRustcVersion
287);
288
289/// A semver crate version (no shape constraints beyond what semver requires).
290///
291/// We delegate validation to the `semver` crate but still expose this newtype
292/// so call sites can switch wire types without hopping back into raw strings.
293#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
294#[serde(transparent)]
295pub struct CrateVersion(pub semver::Version);
296
297// Hand-written so a malformed version answers with the shape it wanted
298// rather than semver's parser position ("unexpected character 's' while
299// parsing major version number") — the request form shows the deserialize
300// error to whoever typed it.
301impl<'de> Deserialize<'de> for CrateVersion {
302    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
303        let raw = String::deserialize(deserializer)?;
304        raw.parse().map_err(serde::de::Error::custom)
305    }
306}
307
308// The wire shape is a string; `semver::Version` has no `PartialSchema` impl.
309impl utoipa::PartialSchema for CrateVersion {
310    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
311        <String as utoipa::PartialSchema>::schema()
312    }
313}
314
315impl utoipa::ToSchema for CrateVersion {}
316
317impl CrateVersion {
318    /// Construct from the embedded semver value.
319    #[must_use]
320    pub const fn new(version: semver::Version) -> Self {
321        Self(version)
322    }
323
324    /// Borrow the underlying semver value.
325    #[must_use]
326    pub const fn as_semver(&self) -> &semver::Version {
327        &self.0
328    }
329
330    /// Consume and return the inner `semver::Version`.
331    #[must_use]
332    pub fn into_inner(self) -> semver::Version {
333        self.0
334    }
335}
336
337impl fmt::Display for CrateVersion {
338    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339        self.0.fmt(f)
340    }
341}
342
343impl FromStr for CrateVersion {
344    type Err = IdentityError;
345    fn from_str(value: &str) -> Result<Self, Self::Err> {
346        semver::Version::parse(value)
347            .map(Self)
348            .map_err(|_| IdentityError::InvalidCrateVersion(value.to_owned()))
349    }
350}
351
352/// A canonical features list: strictly sorted, deduplicated.
353///
354/// On the wire this serializes as a JSON-encoded string (e.g.
355/// `"[\"default\",\"std\"]"`) so it is byte-compatible with the legacy
356/// `features_json: String` shape used by `BuildTaskPayload` and friends.
357#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
358pub struct FeaturesJson(Vec<String>);
359
360// The wire shape is a JSON-encoded string, not an array.
361impl utoipa::PartialSchema for FeaturesJson {
362    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
363        <String as utoipa::PartialSchema>::schema()
364    }
365}
366
367impl utoipa::ToSchema for FeaturesJson {}
368
369impl FeaturesJson {
370    /// Construct from an already-sorted, deduplicated list of features.
371    ///
372    /// # Errors
373    /// Returns [`IdentityError::InvalidFeatureName`] for a malformed feature,
374    /// or [`IdentityError::UnsortedFeatures`] when the list violates the
375    /// sorted/deduplicated invariant.
376    pub fn from_sorted(features: Vec<String>) -> Result<Self, IdentityError> {
377        validate_features_sorted(&features)?;
378        Ok(Self(features))
379    }
380
381    /// Sort, deduplicate, and validate a raw feature list, then construct.
382    ///
383    /// # Errors
384    /// Returns [`IdentityError::InvalidFeatureName`] when a feature name is
385    /// malformed.
386    pub fn canonicalize(mut features: Vec<String>) -> Result<Self, IdentityError> {
387        features.sort();
388        features.dedup();
389        Self::from_sorted(features)
390    }
391
392    /// Borrow the canonical features.
393    #[must_use]
394    pub fn features(&self) -> &[String] {
395        &self.0
396    }
397
398    /// Render the canonical JSON-encoded string used in D1 column storage.
399    ///
400    /// # Panics
401    /// Panics only if `serde_json` fails to serialize a `Vec<String>`, which
402    /// cannot happen.
403    #[must_use]
404    pub fn raw(&self) -> String {
405        serde_json::to_string(&self.0).expect("Vec<String> always serializes")
406    }
407}
408
409impl fmt::Display for FeaturesJson {
410    /// Display as the canonical JSON-encoded array string.
411    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
412        f.write_str(&self.raw())
413    }
414}
415
416impl Serialize for FeaturesJson {
417    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
418        self.raw().serialize(s)
419    }
420}
421
422impl<'de> Deserialize<'de> for FeaturesJson {
423    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
424        let raw = String::deserialize(d)?;
425        let features: Vec<String> = serde_json::from_str(&raw).map_err(|error| {
426            serde::de::Error::custom(IdentityError::InvalidJsonWrapper(error.to_string()))
427        })?;
428        Self::from_sorted(features).map_err(serde::de::Error::custom)
429    }
430}
431
432/// One entry in `dependency_compile_keys_json`: a dependency crate and the
433/// full compile key of the artifact this build resolved it to.
434///
435/// Producers must emit entries sorted by `(crate_name, compile_key)` and
436/// deduplicated; [`Self::canonicalize_list`] does exactly that.
437#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
438pub struct DependencyCompileKeyIdentity {
439    /// Crate name as known to crates.io.
440    pub crate_name: CrateName,
441    /// Blake3 compile key of the dependency artifact.
442    pub compile_key: String,
443}
444
445impl DependencyCompileKeyIdentity {
446    /// Sort and deduplicate a list of identities into canonical order.
447    #[must_use]
448    pub fn canonicalize_list(mut identities: Vec<Self>) -> Vec<Self> {
449        identities.sort();
450        identities.dedup();
451        identities
452    }
453}
454
455/// One entry in `dependency_c_metadata_json`: `(crate_name, c_metadata)`
456/// identifying a dependency artifact already produced by stow.
457#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
458pub struct DependencyCMetadataIdentity {
459    /// Crate name as known to crates.io.
460    pub crate_name: CrateName,
461    /// Cargo `-C metadata` value of the dependency artifact.
462    pub c_metadata: CMetadata,
463}
464
465/// A canonical dependency identity list: strictly sorted by
466/// `(crate_name, c_metadata)`, deduplicated.
467///
468/// On the wire this serializes as a JSON-encoded string (matching the legacy
469/// `dependency_c_metadata_json: String` field shape).
470#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
471pub struct DependencyCMetadataJson(Vec<DependencyCMetadataIdentity>);
472
473// The wire shape is a JSON-encoded string, not an array.
474impl utoipa::PartialSchema for DependencyCMetadataJson {
475    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
476        <String as utoipa::PartialSchema>::schema()
477    }
478}
479
480impl utoipa::ToSchema for DependencyCMetadataJson {}
481
482impl DependencyCMetadataJson {
483    /// Construct from an already-sorted, deduplicated list.
484    ///
485    /// # Errors
486    /// Returns [`IdentityError::UnsortedDependencyIdentities`] when the list
487    /// is not strictly increasing by `(crate_name, c_metadata)`.
488    pub fn from_sorted(
489        identities: Vec<DependencyCMetadataIdentity>,
490    ) -> Result<Self, IdentityError> {
491        let mut previous: Option<(&str, &str)> = None;
492        for identity in &identities {
493            let current = (identity.crate_name.as_str(), identity.c_metadata.as_str());
494            if previous.is_some_and(|last| last >= current) {
495                return Err(IdentityError::UnsortedDependencyIdentities);
496            }
497            previous = Some(current);
498        }
499        Ok(Self(identities))
500    }
501
502    /// Sort, deduplicate, then construct.
503    ///
504    /// # Errors
505    /// Never fails after sorting and deduplication; the `Result` shape
506    /// mirrors [`Self::from_sorted`].
507    pub fn canonicalize(
508        mut identities: Vec<DependencyCMetadataIdentity>,
509    ) -> Result<Self, IdentityError> {
510        identities.sort();
511        identities.dedup();
512        Self::from_sorted(identities)
513    }
514
515    /// Borrow the canonical identities.
516    #[must_use]
517    pub fn entries(&self) -> &[DependencyCMetadataIdentity] {
518        &self.0
519    }
520
521    /// Render the canonical JSON-encoded string used in D1 column storage.
522    ///
523    /// # Panics
524    /// Panics only if `serde_json` fails to serialize the identity list,
525    /// which cannot happen.
526    #[must_use]
527    pub fn raw(&self) -> String {
528        serde_json::to_string(&self.0).expect("dependency identity list always serializes")
529    }
530}
531
532impl fmt::Display for DependencyCMetadataJson {
533    /// Display as the canonical JSON-encoded array string.
534    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
535        f.write_str(&self.raw())
536    }
537}
538
539impl Serialize for DependencyCMetadataJson {
540    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
541        self.raw().serialize(s)
542    }
543}
544
545impl<'de> Deserialize<'de> for DependencyCMetadataJson {
546    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
547        let raw = String::deserialize(d)?;
548        let entries: Vec<DependencyCMetadataIdentity> =
549            serde_json::from_str(&raw).map_err(|error| {
550                serde::de::Error::custom(IdentityError::InvalidJsonWrapper(error.to_string()))
551            })?;
552        Self::from_sorted(entries).map_err(serde::de::Error::custom)
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn crate_name_accepts_typical() {
562        CrateName::parse("serde_json").unwrap();
563        CrateName::parse("proc-macro2").unwrap();
564    }
565
566    #[test]
567    fn crate_name_rejects_empty_and_overlong() {
568        assert!(CrateName::parse("").is_err());
569        assert!(CrateName::parse("x".repeat(129)).is_err());
570        assert!(CrateName::parse("a b").is_err());
571    }
572
573    #[test]
574    fn c_metadata_must_be_hex() {
575        CMetadata::parse("deadbeef").unwrap();
576        assert!(CMetadata::parse("xyz").is_err());
577    }
578
579    #[test]
580    fn rustc_version_shape() {
581        WireRustcVersion::parse("1.83.0").unwrap();
582        WireRustcVersion::parse("1.91.0-nightly").unwrap();
583        assert!(WireRustcVersion::parse("").is_err());
584    }
585
586    #[test]
587    fn features_json_round_trip() {
588        let raw = "[\"default\",\"std\"]";
589        let features: FeaturesJson =
590            serde_json::from_value(serde_json::Value::String(raw.to_owned())).unwrap();
591        assert_eq!(features.features(), &["default", "std"]);
592        let serialized = serde_json::to_value(&features).unwrap();
593        assert_eq!(serialized, serde_json::Value::String(raw.to_owned()));
594    }
595
596    #[test]
597    fn features_json_rejects_unsorted() {
598        let raw = "[\"std\",\"default\"]";
599        let result: Result<FeaturesJson, _> =
600            serde_json::from_value(serde_json::Value::String(raw.to_owned()));
601        assert!(result.is_err());
602    }
603
604    #[test]
605    fn dependency_identities_round_trip() {
606        let raw = "[{\"crate_name\":\"a\",\"c_metadata\":\"deadbeef\"}]";
607        let value: DependencyCMetadataJson =
608            serde_json::from_value(serde_json::Value::String(raw.to_owned())).unwrap();
609        assert_eq!(value.entries().len(), 1);
610        let serialized = serde_json::to_value(&value).unwrap();
611        assert_eq!(serialized, serde_json::Value::String(raw.to_owned()));
612    }
613}