• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

extphprs / ext-php-rs / 36690048961

30 Sep 2026 08:29AM UTC coverage: 70.711% (+0.1%) from 70.575%
36690048961

Pull #798

github

web-flow
Merge 9b8451a3a into be74a46d4
Pull Request #798: fix(array): don't coerce non-canonical numeric strings to integer keys

75 of 75 new or added lines in 2 files covered. (100.0%)

9 existing lines in 1 file now uncovered.

10130 of 14326 relevant lines covered (70.71%)

59.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.44
/src/types/array/array_key.rs
1
use crate::{
2
    boxed::ZBox, convert::FromZval, error::Error, flags::DataType, types::ZendStr, types::Zval,
3
};
4
use std::{convert::TryFrom, fmt::Display};
5

6
/// Represents the key of a PHP array, which can be either a long or a string.
7
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
8
pub enum ArrayKey<'a> {
9
    /// A numerical key.
10
    /// In Zend API it's represented by `u64` (`zend_ulong`), so the value needs
11
    /// to be cast to `zend_ulong` before passing into Zend functions.
12
    Long(i64),
13
    /// A string key.
14
    String(String),
15
    /// A string key by reference.
16
    Str(&'a str),
17
    /// A PHP `zend_string` key.
18
    /// Allows bypassing repeated `zend_string_init` allocations and re-hashing
19
    /// when working with pre-existing or interned PHP strings
20
    ZendString(&'a ZendStr),
21
}
22

23
/// Parses a string array key as an integer the same way PHP does
24
/// (`ZEND_HANDLE_NUMERIC_STR`).
25
///
26
/// Only canonical decimal integers are numeric: an optional `-` followed by
27
/// digits without leading zeros, within range. Strings such as `"+1"`, `"-0"`
28
/// or `"01"` stay string keys.
29
fn parse_numeric_key(key: &str) -> Option<i64> {
184 ✔
30
    let digits = key.strip_prefix('-').unwrap_or(key);
184 ✔
31
    let canonical = match digits.as_bytes() {
184 ✔
32
        [b'0'] => key == "0",
184 ✔
33
        [b'1'..=b'9', rest @ ..] => rest.iter().all(u8::is_ascii_digit),
161 ✔
34
        _ => false,
137 ✔
35
    };
36
    if canonical { key.parse().ok() } else { None }
184 ✔
37
}
184 ✔
38

39
impl From<String> for ArrayKey<'_> {
40
    fn from(value: String) -> Self {
23 ✔
41
        match parse_numeric_key(&value) {
23 ✔
42
            Some(index) => Self::Long(index),
7 ✔
43
            None => Self::String(value),
16 ✔
44
        }
45
    }
23 ✔
46
}
47

48
impl TryFrom<ArrayKey<'_>> for String {
49
    type Error = Error;
50

51
    fn try_from(value: ArrayKey<'_>) -> Result<Self, Self::Error> {
57 ✔
52
        match value {
57 ✔
53
            ArrayKey::String(s) => Ok(s),
37 ✔
54
            ArrayKey::Str(s) => Ok(s.to_string()),
1 ✔
55
            ArrayKey::Long(l) => Ok(l.to_string()),
19 ✔
56
            ArrayKey::ZendString(s) => s.as_str().map(ToString::to_string),
×
57
        }
58
    }
57 ✔
59
}
60

61
impl TryFrom<ArrayKey<'_>> for i64 {
62
    type Error = Error;
63

64
    fn try_from(value: ArrayKey<'_>) -> Result<Self, Self::Error> {
30 ✔
65
        let key = match &value {
30 ✔
66
            ArrayKey::Long(i) => return Ok(*i),
21 ✔
67
            ArrayKey::String(s) => s.as_str(),
7 ✔
68
            ArrayKey::Str(s) => s,
2 ✔
69
            ArrayKey::ZendString(s) => s.as_str().map_err(|_| Error::InvalidUtf8)?,
×
70
        };
71

72
        key.parse::<i64>()
9 ✔
73
            .map_err(|_| Error::ZvalConversion(DataType::String))
9 ✔
74
    }
30 ✔
75
}
76

77
impl ArrayKey<'_> {
78
    /// Check if the key is an integer.
79
    ///
80
    /// # Returns
81
    ///
82
    /// Returns true if the key is an integer, false otherwise.
83
    #[must_use]
84
    pub fn is_long(&self) -> bool {
×
85
        match self {
×
86
            ArrayKey::Long(_) => true,
×
UNCOV
87
            ArrayKey::String(_) | ArrayKey::Str(_) | ArrayKey::ZendString(_) => false,
×
88
        }
UNCOV
89
    }
×
90
}
91

92
impl Display for ArrayKey<'_> {
93
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
519 ✔
94
        match self {
519 ✔
UNCOV
95
            ArrayKey::Long(key) => write!(f, "{key}"),
×
96
            ArrayKey::String(key) => write!(f, "{key}"),
519 ✔
97
            ArrayKey::Str(key) => write!(f, "{key}"),
×
98
            ArrayKey::ZendString(key) => match key.as_str() {
×
99
                Ok(key) => write!(f, "{key}"),
×
UNCOV
100
                Err(_) => write!(f, "{}", String::from_utf8_lossy(key.as_bytes())),
×
101
            },
102
        }
103
    }
519 ✔
104
}
105

106
impl<'a> From<&'a str> for ArrayKey<'a> {
107
    fn from(value: &'a str) -> ArrayKey<'a> {
155 ✔
108
        match parse_numeric_key(value) {
155 ✔
109
            Some(index) => ArrayKey::Long(index),
28 ✔
110
            None => ArrayKey::Str(value),
127 ✔
111
        }
112
    }
155 ✔
113
}
114

115
impl<'a> From<i32> for ArrayKey<'a> {
116
    fn from(index: i32) -> ArrayKey<'a> {
100 ✔
117
        ArrayKey::Long(i64::from(index))
100 ✔
118
    }
100 ✔
119
}
120

121
impl<'a> From<i64> for ArrayKey<'a> {
122
    fn from(index: i64) -> ArrayKey<'a> {
1 ✔
123
        ArrayKey::Long(index)
1 ✔
124
    }
1 ✔
125
}
126

127
impl<'a> From<u64> for ArrayKey<'a> {
128
    fn from(index: u64) -> ArrayKey<'a> {
×
129
        if let Ok(index) = i64::try_from(index) {
×
UNCOV
130
            ArrayKey::Long(index)
×
131
        } else {
UNCOV
132
            ArrayKey::String(index.to_string())
×
133
        }
UNCOV
134
    }
×
135
}
136

137
impl<'a> From<usize> for ArrayKey<'a> {
138
    fn from(index: usize) -> ArrayKey<'a> {
11 ✔
139
        if let Ok(index) = i64::try_from(index) {
11 ✔
140
            ArrayKey::Long(index)
11 ✔
141
        } else {
UNCOV
142
            ArrayKey::String(index.to_string())
×
143
        }
144
    }
11 ✔
145
}
146

147
impl<'a> From<&'a ZBox<ZendStr>> for ArrayKey<'a> {
148
    fn from(value: &'a ZBox<ZendStr>) -> Self {
6 ✔
149
        ArrayKey::from(value.as_ref())
6 ✔
150
    }
6 ✔
151
}
152

153
impl<'a> From<&'a ZendStr> for ArrayKey<'a> {
154
    fn from(value: &'a ZendStr) -> Self {
6 ✔
155
        if let Ok(text) = value.as_str()
6 ✔
156
            && let Some(index) = parse_numeric_key(text)
6 ✔
157
        {
158
            return ArrayKey::Long(index);
3 ✔
159
        }
3 ✔
160
        ArrayKey::ZendString(value)
3 ✔
161
    }
6 ✔
162
}
163

164
impl<'a> FromZval<'a> for ArrayKey<'_> {
165
    const TYPE: DataType = DataType::String;
166

167
    fn from_zval(zval: &'a Zval) -> Option<Self> {
649 ✔
168
        if let Some(key) = zval.long() {
649 ✔
169
            return Some(ArrayKey::Long(key));
81 ✔
170
        }
568 ✔
171
        if let Some(key) = zval.string() {
568 ✔
172
            return Some(ArrayKey::String(key));
568 ✔
173
        }
×
UNCOV
174
        None
×
175
    }
649 ✔
176
}
177

178
#[cfg(test)]
179
#[cfg(feature = "embed")]
180
#[allow(clippy::unwrap_used)]
181
mod tests {
182
    use crate::error::Error;
183
    use crate::types::ArrayKey;
184

185
    #[test]
186
    fn test_string_try_from_array_key() {
1 ✔
187
        let key = ArrayKey::String("test".to_string());
1 ✔
188
        let result: crate::error::Result<String, _> = key.try_into();
1 ✔
189
        assert!(result.is_ok());
1 ✔
190
        assert_eq!(result.unwrap(), "test".to_string());
1 ✔
191

192
        let key = ArrayKey::Str("test");
1 ✔
193
        let result: crate::error::Result<String, _> = key.try_into();
1 ✔
194
        assert!(result.is_ok());
1 ✔
195
        assert_eq!(result.unwrap(), "test".to_string());
1 ✔
196

197
        let key = ArrayKey::Long(42);
1 ✔
198
        let result: crate::error::Result<String, _> = key.try_into();
1 ✔
199
        assert_eq!(result.unwrap(), "42".to_string());
1 ✔
200

201
        let key = ArrayKey::String("42".to_string());
1 ✔
202
        let result: crate::error::Result<String, _> = key.try_into();
1 ✔
203
        assert!(result.is_ok());
1 ✔
204
        assert_eq!(result.unwrap(), "42".to_string());
1 ✔
205

206
        let key = ArrayKey::Str("123");
1 ✔
207
        let result: crate::error::Result<i64, _> = key.try_into();
1 ✔
208
        assert!(result.is_ok());
1 ✔
209
        assert_eq!(result.unwrap(), 123);
1 ✔
210
    }
1 ✔
211

212
    #[test]
213
    fn test_i64_try_from_array_key() {
1 ✔
214
        let key = ArrayKey::Long(42);
1 ✔
215
        let result: crate::error::Result<i64, _> = key.try_into();
1 ✔
216
        assert!(result.is_ok());
1 ✔
217
        assert_eq!(result.unwrap(), 42);
1 ✔
218

219
        let key = ArrayKey::String("42".to_string());
1 ✔
220
        let result: crate::error::Result<i64, _> = key.try_into();
1 ✔
221
        assert!(result.is_ok());
1 ✔
222
        assert_eq!(result.unwrap(), 42);
1 ✔
223

224
        let key = ArrayKey::Str("123");
1 ✔
225
        let result: crate::error::Result<i64, _> = key.try_into();
1 ✔
226
        assert!(result.is_ok());
1 ✔
227
        assert_eq!(result.unwrap(), 123);
1 ✔
228

229
        let key = ArrayKey::String("not a number".to_string());
1 ✔
230
        let result: crate::error::Result<i64, _> = key.try_into();
1 ✔
231
        assert!(result.is_err());
1 ✔
232
        assert!(matches!(result.unwrap_err(), Error::ZvalConversion(_)));
1 ✔
233
    }
1 ✔
234

235
    #[test]
236
    fn test_from_str_with_leading_zeros() {
1 ✔
237
        let key: ArrayKey = "00".into();
1 ✔
238
        assert_eq!(key, ArrayKey::Str("00"));
1 ✔
239
        let key: ArrayKey = "071".into();
1 ✔
240
        assert_eq!(key, ArrayKey::Str("071"));
1 ✔
241
        let key: ArrayKey = "0".into();
1 ✔
242
        assert_eq!(key, ArrayKey::Long(0));
1 ✔
243
    }
1 ✔
244

245
    #[test]
246
    fn test_from_str_matches_php_numeric_keys() {
1 ✔
247
        for (key, expected) in [
5 ✔
248
            ("1", 1),
1 ✔
249
            ("-1", -1),
1 ✔
250
            ("0", 0),
1 ✔
251
            ("9223372036854775807", i64::MAX),
1 ✔
252
            ("-9223372036854775808", i64::MIN),
1 ✔
253
        ] {
1 ✔
254
            assert_eq!(ArrayKey::from(key), ArrayKey::Long(expected), "{key:?}");
5 ✔
255
            assert_eq!(
5 ✔
256
                ArrayKey::from(key.to_string()),
5 ✔
257
                ArrayKey::Long(expected),
5 ✔
258
                "{key:?}"
259
            );
260
        }
261

262
        for key in [
10 ✔
263
            "+1",
1 ✔
264
            "+0",
1 ✔
265
            "-0",
1 ✔
266
            "-01",
1 ✔
267
            "-",
1 ✔
268
            "",
1 ✔
269
            " 1",
1 ✔
270
            "1 ",
1 ✔
271
            "9223372036854775808",
1 ✔
272
            "-9223372036854775809",
1 ✔
273
        ] {
1 ✔
274
            assert_eq!(ArrayKey::from(key), ArrayKey::Str(key), "{key:?}");
10 ✔
275
            assert_eq!(
10 ✔
276
                ArrayKey::from(key.to_string()),
10 ✔
277
                ArrayKey::String(key.to_string()),
10 ✔
278
                "{key:?}"
279
            );
280
        }
281
    }
1 ✔
282

283
    #[test]
284
    fn test_from_string_with_leading_zeros() {
1 ✔
285
        let key = ArrayKey::String("042".to_string());
1 ✔
286
        let result: crate::error::Result<String, _> = key.try_into();
1 ✔
287
        assert!(result.is_ok());
1 ✔
288
        assert_eq!(result.unwrap(), "042");
1 ✔
289
        let key = ArrayKey::String("00".to_string());
1 ✔
290
        let result: crate::error::Result<String, _> = key.try_into();
1 ✔
291
        assert!(result.is_ok());
1 ✔
292
        assert_eq!(result.unwrap(), "00");
1 ✔
293
        let key = ArrayKey::String("0".to_string());
1 ✔
294
        let result: crate::error::Result<i64, _> = key.try_into();
1 ✔
295
        assert!(result.is_ok());
1 ✔
296
        assert_eq!(result.unwrap(), 0);
1 ✔
297
    }
1 ✔
298
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc