use crate::error::Error;
use crate::parse::{create_secure_reader, optional_attr, read_event, required_attr};
use quick_xml::events::Event;
use std::collections::HashMap;
use std::io::BufRead;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Relationship {
pub id: String,
pub rel_type: String,
pub target: String,
pub target_mode: TargetMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TargetMode {
Internal,
External,
}
pub(crate) type RelationshipMap = HashMap<String, Relationship>;
pub(crate) fn parse_relationships(
reader: impl BufRead,
part_dir: &str,
path: &str,
) -> Result<RelationshipMap, Error> {
let mut xml_reader = create_secure_reader(reader);
let mut buf = Vec::new();
let mut map = RelationshipMap::new();
loop {
let event = read_event(&mut xml_reader, &mut buf, path)?;
match &event {
Event::Start(start) | Event::Empty(start)
if start.local_name().as_ref() == b"Relationship" =>
{
let id = required_attr(start, path, "Id")?;
let rel_type = required_attr(start, path, "Type")?;
let target_attr = required_attr(start, path, "Target")?;
let target_mode = match optional_attr(start, path, "TargetMode")?.as_deref() {
Some("External") => TargetMode::External,
_ => TargetMode::Internal,
};
let target = match target_mode {
TargetMode::External => target_attr,
TargetMode::Internal => resolve_target_path(part_dir, &target_attr),
};
map.insert(
id.clone(),
Relationship {
id,
rel_type,
target,
target_mode,
},
);
}
Event::Eof => break,
_ => {}
}
buf.clear();
}
Ok(map)
}
fn resolve_target_path(base_dir: &str, target: &str) -> String {
let mut segments: Vec<&str> = if target.starts_with('/') {
Vec::new()
} else {
base_dir.split('/').filter(|s| !s.is_empty()).collect()
};
for part in target.split('/') {
match part {
"" | "." => {}
".." => {
segments.pop();
}
seg => segments.push(seg),
}
}
segments.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(xml: &[u8], part_dir: &str) -> RelationshipMap {
parse_relationships(xml, part_dir, "xl/_rels/workbook.xml.rels").unwrap()
}
#[test]
fn parses_multiple_relationships() {
let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
</Relationships>"#;
let map = parse(xml, "xl");
assert_eq!(map.len(), 2);
assert_eq!(map["rId1"].target, "xl/worksheets/sheet1.xml");
assert_eq!(map["rId1"].target_mode, TargetMode::Internal);
assert_eq!(map["rId2"].target, "xl/sharedStrings.xml");
}
#[test]
fn missing_id_is_an_error() {
let xml = br#"<Relationships><Relationship Type="t" Target="a"/></Relationships>"#;
let err = parse_relationships(&xml[..], "xl", "path").unwrap_err();
assert!(matches!(
err,
Error::MissingRequiredElement { name: "Id", .. }
));
}
#[test]
fn missing_type_is_an_error() {
let xml = br#"<Relationships><Relationship Id="rId1" Target="a"/></Relationships>"#;
let err = parse_relationships(&xml[..], "xl", "path").unwrap_err();
assert!(matches!(
err,
Error::MissingRequiredElement { name: "Type", .. }
));
}
#[test]
fn missing_target_is_an_error() {
let xml = br#"<Relationships><Relationship Id="rId1" Type="t"/></Relationships>"#;
let err = parse_relationships(&xml[..], "xl", "path").unwrap_err();
assert!(matches!(
err,
Error::MissingRequiredElement { name: "Target", .. }
));
}
#[test]
fn external_target_mode_keeps_target_verbatim() {
let xml = br#"<Relationships><Relationship Id="rId3" Type="t" Target="https://example.com/x" TargetMode="External"/></Relationships>"#;
let map = parse(xml, "xl");
assert_eq!(map["rId3"].target, "https://example.com/x");
assert_eq!(map["rId3"].target_mode, TargetMode::External);
}
#[test]
fn empty_relationships_produces_empty_map() {
let xml = b"<Relationships></Relationships>";
assert!(parse(xml, "xl").is_empty());
}
#[test]
fn resolve_target_path_simple_relative() {
assert_eq!(
resolve_target_path("xl", "worksheets/sheet1.xml"),
"xl/worksheets/sheet1.xml"
);
}
#[test]
fn resolve_target_path_with_parent_dir() {
assert_eq!(
resolve_target_path("xl/worksheets", "../media/image1.png"),
"xl/media/image1.png"
);
}
#[test]
fn resolve_target_path_excessive_parent_dir_does_not_panic() {
let result = resolve_target_path("xl", "../../evil");
assert_eq!(result, "evil");
}
#[test]
fn resolve_target_path_package_absolute_target_ignores_base_dir() {
assert_eq!(
resolve_target_path("xl", "/xl/worksheets/sheet1.xml"),
"xl/worksheets/sheet1.xml"
);
}
#[test]
fn resolve_target_path_package_absolute_target_with_excessive_parent_dir_does_not_panic() {
let result = resolve_target_path("xl", "/../../evil");
assert_eq!(result, "evil");
}
}