Skip to content

Commit a22032c

Browse files
committed
fix: validate Penpot member names portably
1 parent af9d0e7 commit a22032c

1 file changed

Lines changed: 61 additions & 7 deletions

File tree

crates/nuif-penpot/src/lib.rs

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -603,7 +603,7 @@ fn read_archive(bytes: &[u8]) -> Result<Vec<PackageMember>, AdapterError> {
603603
.by_index(index)
604604
.map_err(|error| AdapterError::Zip(error.to_string()))?;
605605
let name = file.name().to_owned();
606-
if !portable_member_name(&file, &name) {
606+
if !portable_member_name(&name) {
607607
return Err(AdapterError::UnsafeMemberName(name));
608608
}
609609
if !names.insert(name.clone()) {
@@ -656,12 +656,32 @@ fn read_archive(bytes: &[u8]) -> Result<Vec<PackageMember>, AdapterError> {
656656
Ok(members)
657657
}
658658

659-
fn portable_member_name<R: Read>(file: &zip::read::ZipFile<'_, R>, name: &str) -> bool {
660-
name.is_ascii()
661-
&& !name.contains('\\')
662-
&& file
663-
.enclosed_name()
664-
.is_some_and(|path| path.to_string_lossy() == name)
659+
fn portable_member_name(name: &str) -> bool {
660+
!name.is_empty()
661+
&& name.is_ascii()
662+
&& name.bytes().all(|byte| {
663+
byte >= b' '
664+
&& byte != 0x7f
665+
&& !matches!(byte, b'\\' | b'<' | b'>' | b':' | b'"' | b'|' | b'?' | b'*')
666+
})
667+
&& name.split('/').all(|segment| {
668+
!segment.is_empty()
669+
&& !matches!(segment, "." | "..")
670+
&& !segment.ends_with(['.', ' '])
671+
&& !windows_reserved_segment(segment)
672+
})
673+
}
674+
675+
fn windows_reserved_segment(segment: &str) -> bool {
676+
let stem = segment.split_once('.').map_or(segment, |(stem, _)| stem);
677+
["CON", "PRN", "AUX", "NUL"]
678+
.into_iter()
679+
.any(|reserved| stem.eq_ignore_ascii_case(reserved))
680+
|| (stem.len() == 4
681+
&& ["COM", "LPT"]
682+
.into_iter()
683+
.any(|reserved| stem[..3].eq_ignore_ascii_case(reserved))
684+
&& matches!(stem.as_bytes()[3], b'1'..=b'9'))
665685
}
666686

667687
fn write_archive(members: &[PackageMember]) -> Result<Vec<u8>, AdapterError> {
@@ -1952,6 +1972,40 @@ mod tests {
19521972
));
19531973
}
19541974

1975+
#[test]
1976+
fn member_names_are_host_independent_and_traversal_safe() {
1977+
for name in [
1978+
"manifest.json",
1979+
"files/00000000-0000-0000-0000-000000000001.json",
1980+
"objects/design probe.bin",
1981+
] {
1982+
assert!(
1983+
portable_member_name(name),
1984+
"expected {name:?} to be portable"
1985+
);
1986+
}
1987+
for name in [
1988+
"",
1989+
"/manifest.json",
1990+
"files/",
1991+
"files//shape.json",
1992+
"files/./shape.json",
1993+
"files/../manifest.json",
1994+
"files\\shape.json",
1995+
"C:/shape.json",
1996+
"files/shape?.json",
1997+
"files/shape. ",
1998+
"files/CON.json",
1999+
"files/lpt9",
2000+
"files/shape\u{7f}.json",
2001+
] {
2002+
assert!(
2003+
!portable_member_name(name),
2004+
"expected {name:?} to be rejected"
2005+
);
2006+
}
2007+
}
2008+
19552009
#[test]
19562010
fn writer_rejects_duplicate_member_names() {
19572011
let cursor = Cursor::new(Vec::new());

0 commit comments

Comments
 (0)