deps_core/interpolation.rs
1//! Bounded property/variable values for manifest interpolation (issue #1481).
2//!
3//! Maven's `${property}` and Gradle's `version.ref`/`$name`/`${name}` substitutions each
4//! resolve through a `HashMap` keyed by property name. Without a per-value bound, a manifest
5//! declaring up to [`deps_core::MAX_DEPENDENCIES_PER_DOCUMENT`](crate::MAX_DEPENDENCIES_PER_DOCUMENT)
6//! kept dependencies, each resolving an unbounded property value, retains
7//! `kept_deps * value_len` bytes with no cap on the second factor (CWE-400). [`PropertyValue`]
8//! makes an oversized value unrepresentable in the map that feeds resolution, so every
9//! reference to it falls through the existing "unresolved" path instead of retaining the raw
10//! text.
11
12/// Maximum byte length of a single interpolated property/variable value.
13///
14/// Real version strings and version ranges are well under 100 bytes; 1 KiB leaves headroom
15/// for `systemPath`-style path values while still bounding worst-case retained memory to
16/// `MAX_DEPENDENCIES_PER_DOCUMENT * MAX_INTERPOLATED_VALUE_BYTES` per resolution pass.
17pub const MAX_INTERPOLATED_VALUE_BYTES: usize = 1024;
18
19/// A property/variable value that has been checked against
20/// [`MAX_INTERPOLATED_VALUE_BYTES`] and is guaranteed not to exceed it.
21///
22/// This is the type-level gate for issue #1481: `HashMap<String, PropertyValue>` (rather than
23/// `HashMap<String, String>`) as the map type that feeds Maven's `${property}` and Gradle's
24/// `version.ref`/`$name`/`${name}` resolution makes an oversized retained value
25/// unrepresentable, instead of relying on a runtime check at every call site.
26///
27/// # Examples
28///
29/// ```
30/// use deps_core::interpolation::{MAX_INTERPOLATED_VALUE_BYTES, PropertyValue};
31///
32/// let value = PropertyValue::new("1.2.3".to_string()).unwrap();
33/// assert_eq!(value.as_str(), "1.2.3");
34///
35/// let oversized = "x".repeat(MAX_INTERPOLATED_VALUE_BYTES + 1);
36/// assert!(PropertyValue::new(oversized).is_err());
37/// ```
38#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39pub struct PropertyValue(String);
40
41impl PropertyValue {
42 /// Wraps `value` as a `PropertyValue`, rejecting it if longer than
43 /// [`MAX_INTERPOLATED_VALUE_BYTES`].
44 ///
45 /// # Errors
46 ///
47 /// Returns [`OversizedPropertyValue`] if `value.len()` exceeds
48 /// [`MAX_INTERPOLATED_VALUE_BYTES`].
49 ///
50 /// # Examples
51 ///
52 /// ```
53 /// use deps_core::interpolation::PropertyValue;
54 ///
55 /// assert!(PropertyValue::new("2.0.16".to_string()).is_ok());
56 /// ```
57 pub fn new(value: String) -> Result<Self, OversizedPropertyValue> {
58 if value.len() > MAX_INTERPOLATED_VALUE_BYTES {
59 return Err(OversizedPropertyValue { len: value.len() });
60 }
61 Ok(Self(value))
62 }
63
64 /// Returns the property value as a string slice.
65 ///
66 /// # Examples
67 ///
68 /// ```
69 /// use deps_core::interpolation::PropertyValue;
70 ///
71 /// let value = PropertyValue::new("3.2.0".to_string()).unwrap();
72 /// assert_eq!(value.as_str(), "3.2.0");
73 /// ```
74 #[must_use]
75 pub fn as_str(&self) -> &str {
76 &self.0
77 }
78
79 /// Returns the byte length of the property value.
80 ///
81 /// # Examples
82 ///
83 /// ```
84 /// use deps_core::interpolation::PropertyValue;
85 ///
86 /// let value = PropertyValue::new("3.2.0".to_string()).unwrap();
87 /// assert_eq!(value.len(), 5);
88 /// ```
89 #[must_use]
90 pub fn len(&self) -> usize {
91 self.0.len()
92 }
93
94 /// Returns whether the property value is empty.
95 ///
96 /// # Examples
97 ///
98 /// ```
99 /// use deps_core::interpolation::PropertyValue;
100 ///
101 /// let value = PropertyValue::new(String::new()).unwrap();
102 /// assert!(value.is_empty());
103 /// ```
104 #[must_use]
105 pub fn is_empty(&self) -> bool {
106 self.0.is_empty()
107 }
108}
109
110/// Inserts `value` into `map` under `key`, dropping it instead if it exceeds
111/// [`MAX_INTERPOLATED_VALUE_BYTES`] (#1481) rather than retaining an unbounded value.
112///
113/// Every ecosystem's own bounded-property map (`deps-maven`'s `properties`, `deps-gradle`'s
114/// `version_refs` and `gradle.properties` map) shares this same insert-time gate instead of
115/// each reimplementing the check-log-drop pattern independently. Logs at `debug` with the
116/// value's length and `context` only — never the value's content — so a caller can tell
117/// which map dropped a value without leaking a potentially large or sensitive payload into
118/// tracing output. A dropped value falls through to whatever "unresolved" behavior the
119/// caller's own lookup path already has for a missing key.
120///
121/// # Examples
122///
123/// ```
124/// use deps_core::interpolation::{MAX_INTERPOLATED_VALUE_BYTES, insert_bounded};
125/// use std::collections::HashMap;
126///
127/// let mut map = HashMap::new();
128/// insert_bounded(&mut map, "ver".to_string(), "1.2.3".to_string(), "example");
129/// assert_eq!(map.get("ver").map(|v| v.as_str()), Some("1.2.3"));
130///
131/// insert_bounded(
132/// &mut map,
133/// "big".to_string(),
134/// "x".repeat(MAX_INTERPOLATED_VALUE_BYTES + 1),
135/// "example",
136/// );
137/// assert!(!map.contains_key("big"));
138/// ```
139pub fn insert_bounded(
140 map: &mut std::collections::HashMap<String, PropertyValue>,
141 key: String,
142 value: String,
143 context: &str,
144) {
145 match PropertyValue::new(value) {
146 Ok(value) => {
147 map.insert(key, value);
148 }
149 Err(err) => {
150 tracing::debug!(
151 context,
152 len = err.len,
153 "property value exceeds cap; dropping"
154 );
155 }
156 }
157}
158
159/// A property/variable value exceeded [`MAX_INTERPOLATED_VALUE_BYTES`] and was rejected by
160/// [`PropertyValue::new`].
161///
162/// # Examples
163///
164/// ```
165/// use deps_core::interpolation::{MAX_INTERPOLATED_VALUE_BYTES, PropertyValue};
166///
167/// let err = PropertyValue::new("x".repeat(MAX_INTERPOLATED_VALUE_BYTES + 1)).unwrap_err();
168/// assert_eq!(err.len, MAX_INTERPOLATED_VALUE_BYTES + 1);
169/// ```
170#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
171#[error("property value length {len} exceeds cap of {MAX_INTERPOLATED_VALUE_BYTES} bytes")]
172pub struct OversizedPropertyValue {
173 /// The rejected value's byte length.
174 pub len: usize,
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn accepts_value_at_exact_cap() {
183 let value = "x".repeat(MAX_INTERPOLATED_VALUE_BYTES);
184 assert!(PropertyValue::new(value).is_ok());
185 }
186
187 #[test]
188 fn rejects_value_one_byte_over_cap() {
189 let value = "x".repeat(MAX_INTERPOLATED_VALUE_BYTES + 1);
190 let err = PropertyValue::new(value).unwrap_err();
191 assert_eq!(err.len, MAX_INTERPOLATED_VALUE_BYTES + 1);
192 }
193}