1use anyhow::{Context, Result};
24use flate2::read::ZlibDecoder;
25use flate2::write::ZlibEncoder;
26use flate2::Compression;
27use log::{debug, info};
28use serde::{Deserialize, Serialize};
29use std::collections::{BTreeMap, HashMap, HashSet};
30use std::io::{Read, Write};
31use std::path::Path;
32use tokio::fs;
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
36pub struct InventoryItem {
37 pub project_name: String,
38 pub project_version: String,
39 pub uri: String,
40 pub display_name: String,
41}
42
43impl InventoryItem {
44 pub fn new(
45 project_name: String,
46 project_version: String,
47 uri: String,
48 display_name: String,
49 ) -> Self {
50 Self {
51 project_name,
52 project_version,
53 uri,
54 display_name,
55 }
56 }
57}
58
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
70pub struct Inventory {
71 pub data: BTreeMap<String, BTreeMap<String, InventoryItem>>,
72}
73
74impl Inventory {
75 pub fn new() -> Self {
76 Self {
77 data: BTreeMap::new(),
78 }
79 }
80
81 pub fn insert(&mut self, obj_type: String, name: String, item: InventoryItem) {
83 self.data.entry(obj_type).or_default().insert(name, item);
84 }
85
86 pub fn get(&self, obj_type: &str, name: &str) -> Option<&InventoryItem> {
88 self.data.get(obj_type)?.get(name)
89 }
90
91 pub fn contains(&self, obj_type: &str, name: &str) -> bool {
93 self.data
94 .get(obj_type)
95 .is_some_and(|objects| objects.contains_key(name))
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct InvObject {
105 pub name: String,
106 pub objtype: String,
107 pub priority: i32,
108 pub docname: String,
109 pub anchor: String,
110 pub dispname: String,
111}
112
113pub fn posix_join(uri: &str, location: &str) -> String {
118 if location.starts_with('/') {
119 location.to_string()
120 } else if uri.is_empty() || uri.ends_with('/') {
121 format!("{uri}{location}")
122 } else {
123 format!("{uri}/{location}")
124 }
125}
126
127lazy_static::lazy_static! {
128 static ref V2_LINE_RE: regex::Regex =
132 regex::Regex::new(r"^(.+?)\s+(\S+)\s+(-?\d+)\s+?(\S*)\s+(.*)").unwrap();
133 static ref WHITESPACE_RUN_RE: regex::Regex = regex::Regex::new(r"\s+").unwrap();
137}
138
139pub struct InventoryFile;
141
142impl InventoryFile {
143 pub fn loads(content: &[u8], uri: &str) -> Result<Inventory> {
149 let (format_line, rest) = partition_bytes(content, b'\n');
150 let format_line = rstrip_bytes(format_line);
151
152 if format_line == b"# Sphinx inventory version 2" {
153 Self::loads_v2(rest, uri)
154 } else if format_line == b"# Sphinx inventory version 1" {
155 Self::loads_v1(rest, uri)
156 } else if let Some(unknown_version_bytes) =
157 format_line.strip_prefix(b"# Sphinx inventory version ")
158 {
159 let unknown_version = String::from_utf8(unknown_version_bytes.to_vec())
160 .context("inventory header version suffix is not valid UTF-8")?;
161 anyhow::bail!(
162 "unknown or unsupported inventory version: {}",
163 python_repr_str(&unknown_version)
164 );
165 } else {
166 let line = String::from_utf8(format_line.to_vec())
167 .context("inventory header line is not valid UTF-8")?;
168 anyhow::bail!("invalid inventory header: {}", line);
169 }
170 }
171
172 pub async fn load<P: AsRef<Path>>(filename: P, uri: &str) -> Result<Inventory> {
174 let content = fs::read(filename.as_ref()).await.with_context(|| {
175 format!(
176 "Failed to read inventory file: {}",
177 filename.as_ref().display()
178 )
179 })?;
180
181 Self::loads(&content, uri)
182 }
183
184 fn loads_v1(content: &[u8], uri: &str) -> Result<Inventory> {
190 let text =
191 String::from_utf8(content.to_vec()).context("v1 inventory body is not valid UTF-8")?;
192 let lines = python_str_splitlines(&text);
193
194 if lines.len() < 2 {
195 anyhow::bail!("invalid inventory header: missing project name or version");
196 }
197
198 let mut inv = Inventory::new();
199 let projname = str_slice_from_char(lines[0].trim_end(), 11).to_string();
200 let version = str_slice_from_char(lines[1].trim_end(), 11).to_string();
201
202 for line in &lines[2..] {
203 let fields = python_split_none_maxsplit(line.trim_end(), 2);
204 if fields.len() != 3 {
205 anyhow::bail!(
206 "invalid inventory v1 entry (expected `name type location`): {}",
207 line
208 );
209 }
210 let (name, item_type, location) = (fields[0], fields[1], fields[2]);
211 let mut location = posix_join(uri, location);
212
213 let domain_type = if item_type == "mod" {
217 location.push_str("#module-");
218 location.push_str(name);
219 "py:module".to_string()
220 } else {
221 location.push('#');
222 location.push_str(name);
223 format!("py:{item_type}")
224 };
225
226 let item =
227 InventoryItem::new(projname.clone(), version.clone(), location, "-".to_string());
228 inv.insert(domain_type, name.to_string(), item);
229 }
230
231 Ok(inv)
232 }
233
234 fn loads_v2(content: &[u8], uri: &str) -> Result<Inventory> {
241 let parts = splitn_bytes(content, b'\n', 4);
242 if parts.len() != 4 {
243 anyhow::bail!("invalid inventory header: missing project name or version");
244 }
245 let (line_1, line_2, check_line, compressed) = (parts[0], parts[1], parts[2], parts[3]);
246
247 let projname = String::from_utf8(bytes_slice_from(rstrip_bytes(line_1), 11).to_vec())
250 .context("inventory Project header is not valid UTF-8")?;
251 let version = String::from_utf8(bytes_slice_from(rstrip_bytes(line_2), 11).to_vec())
252 .context("inventory Version header is not valid UTF-8")?;
253
254 if !contains_bytes(check_line, b"zlib") {
256 let check_line_text = String::from_utf8(check_line.to_vec())
257 .context("inventory compression-check line is not valid UTF-8")?;
258 anyhow::bail!(
259 "invalid inventory header (not compressed): {}",
260 check_line_text
261 );
262 }
263
264 let decompressed = decompress_zlib(compressed)?;
265 let decompressed_text = String::from_utf8(decompressed)
266 .context("decompressed inventory payload is not valid UTF-8")?;
267
268 let mut inv = Inventory::new();
269 let mut potential_ambiguities: HashMap<String, (String, String, String)> = HashMap::new();
272 let mut actual_ambiguities: HashSet<String> = HashSet::new();
273
274 for line in python_str_splitlines(&decompressed_text) {
275 let trimmed = line.trim_end();
276 let Some(caps) = V2_LINE_RE.captures(trimmed) else {
277 continue;
278 };
279 let name = caps.get(1).unwrap().as_str();
280 let type_ = caps.get(2).unwrap().as_str();
281 let prio = caps.get(3).unwrap().as_str();
282 let mut location = caps.get(4).unwrap().as_str().to_string();
283 let dispname = caps.get(5).unwrap().as_str().to_string();
284
285 if !type_.contains(':') {
286 continue;
289 }
290 if type_ == "py:module" && inv.contains(type_, name) {
291 continue;
293 }
294
295 if type_ == "std:label" || type_ == "std:term" {
296 let definition = format!("{type_}:{name}");
297 let content_key = (prio.to_string(), location.clone(), dispname.clone());
298 let lowercase_definition = definition.to_lowercase();
299 match potential_ambiguities.get(&lowercase_definition) {
300 Some(existing) if existing == &content_key => {
301 debug!(
302 "inventory <{}> contains duplicate definitions of {}",
303 uri, definition
304 );
305 }
306 Some(_) => {
307 actual_ambiguities.insert(definition);
308 }
309 None => {
310 potential_ambiguities.insert(lowercase_definition, content_key);
311 }
312 }
313 }
314
315 if let Some(prefix) = location.strip_suffix('$') {
316 location = format!("{prefix}{name}");
317 }
318 let joined = posix_join(uri, &location);
319
320 let item = InventoryItem::new(projname.clone(), version.clone(), joined, dispname);
321 inv.insert(type_.to_string(), name.to_string(), item);
322 }
323
324 for ambiguity in &actual_ambiguities {
325 info!(
326 "inventory <{}> contains multiple definitions for {}",
327 uri, ambiguity
328 );
329 }
330
331 Ok(inv)
332 }
333
334 pub async fn dump<P: AsRef<Path>>(
348 path: P,
349 project: &str,
350 version: &str,
351 domains: &[(&str, Vec<InvObject>)],
352 get_target_uri: impl Fn(&str) -> String,
353 ) -> Result<()> {
354 let header = format!(
355 "# Sphinx inventory version 2\n\
356 # Project: {}\n\
357 # Version: {}\n\
358 # The remainder of this file is compressed using zlib.\n",
359 Self::escape_string(project),
360 Self::escape_string(version),
361 );
362
363 let mut sorted_domains: Vec<&(&str, Vec<InvObject>)> = domains.iter().collect();
364 sorted_domains.sort_by_key(|(name, _)| *name);
365
366 let mut body = Vec::new();
367 for (domain_name, objects) in sorted_domains {
368 let mut objects: Vec<&InvObject> = objects.iter().collect();
369 objects.sort_by(|a, b| {
370 a.name
371 .cmp(&b.name)
372 .then_with(|| a.dispname.cmp(&b.dispname))
373 .then_with(|| a.objtype.cmp(&b.objtype))
374 .then_with(|| a.docname.cmp(&b.docname))
375 .then_with(|| a.anchor.cmp(&b.anchor))
376 .then_with(|| a.priority.cmp(&b.priority))
377 });
378
379 for obj in objects {
380 let anchor = match obj.anchor.strip_suffix(obj.name.as_str()) {
383 Some(prefix) => format!("{prefix}$"),
384 None => obj.anchor.clone(),
385 };
386
387 let mut uri = get_target_uri(&obj.docname);
391 if !anchor.is_empty() {
392 uri.push('#');
393 uri.push_str(&anchor);
394 }
395
396 let dispname: &str = if obj.dispname == obj.name {
397 "-"
398 } else {
399 obj.dispname.as_str()
400 };
401
402 let line = format!(
403 "{} {}:{} {} {} {}\n",
404 obj.name, domain_name, obj.objtype, obj.priority, uri, dispname
405 );
406 body.extend_from_slice(line.as_bytes());
407 }
408 }
409
410 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::new(9));
417 encoder
418 .write_all(&body)
419 .context("failed to compress inventory body")?;
420 let compressed = encoder
421 .finish()
422 .context("failed to finalize inventory zlib stream")?;
423
424 let mut content = header.into_bytes();
425 content.extend_from_slice(&compressed);
426
427 fs::write(path, content)
428 .await
429 .context("Failed to write inventory file")?;
430
431 Ok(())
432 }
433
434 fn escape_string(s: &str) -> String {
438 WHITESPACE_RUN_RE.replace_all(s, " ").to_string()
439 }
440}
441
442fn partition_bytes(data: &[u8], sep: u8) -> (&[u8], &[u8]) {
447 match data.iter().position(|&b| b == sep) {
448 Some(pos) => (&data[..pos], &data[pos + 1..]),
449 None => (data, &[]),
450 }
451}
452
453fn splitn_bytes(data: &[u8], sep: u8, n: usize) -> Vec<&[u8]> {
457 let mut parts = Vec::with_capacity(n);
458 let mut rest = data;
459 while parts.len() + 1 < n {
460 match rest.iter().position(|&b| b == sep) {
461 Some(pos) => {
462 parts.push(&rest[..pos]);
463 rest = &rest[pos + 1..];
464 }
465 None => break,
466 }
467 }
468 parts.push(rest);
469 parts
470}
471
472fn rstrip_bytes(data: &[u8]) -> &[u8] {
475 let mut end = data.len();
476 while end > 0 && matches!(data[end - 1], b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c) {
477 end -= 1;
478 }
479 &data[..end]
480}
481
482fn bytes_slice_from(data: &[u8], start: usize) -> &[u8] {
485 if start >= data.len() {
486 &[]
487 } else {
488 &data[start..]
489 }
490}
491
492fn str_slice_from_char(s: &str, start: usize) -> &str {
496 match s.char_indices().nth(start) {
497 Some((byte_idx, _)) => &s[byte_idx..],
498 None => "",
499 }
500}
501
502fn contains_bytes(data: &[u8], needle: &[u8]) -> bool {
504 if needle.is_empty() {
505 return true;
506 }
507 data.windows(needle.len()).any(|w| w == needle)
508}
509
510fn python_str_splitlines(s: &str) -> Vec<&str> {
515 let mut lines = Vec::new();
516 let mut start = 0usize;
517 let mut chars = s.char_indices().peekable();
518 while let Some((idx, ch)) = chars.next() {
519 let is_boundary = matches!(
520 ch,
521 '\n' | '\r'
522 | '\u{0b}'
523 | '\u{0c}'
524 | '\u{1c}'
525 | '\u{1d}'
526 | '\u{1e}'
527 | '\u{85}'
528 | '\u{2028}'
529 | '\u{2029}'
530 );
531 if is_boundary {
532 lines.push(&s[start..idx]);
533 let mut end = idx + ch.len_utf8();
534 if ch == '\r' {
535 if let Some(&(_, '\n')) = chars.peek() {
536 let (nidx, nch) = chars.next().unwrap();
537 end = nidx + nch.len_utf8();
538 }
539 }
540 start = end;
541 }
542 }
543 if start < s.len() {
544 lines.push(&s[start..]);
545 }
546 lines
547}
548
549fn python_split_none_maxsplit(s: &str, maxsplit: usize) -> Vec<&str> {
554 let mut result = Vec::new();
555 let mut rest = s;
556 loop {
557 let trimmed = rest.trim_start();
558 if trimmed.is_empty() {
559 break;
560 }
561 if result.len() == maxsplit {
562 result.push(trimmed);
563 break;
564 }
565 match trimmed.find(char::is_whitespace) {
566 Some(idx) => {
567 result.push(&trimmed[..idx]);
568 rest = &trimmed[idx..];
569 }
570 None => {
571 result.push(trimmed);
572 rest = "";
573 }
574 }
575 }
576 result
577}
578
579fn python_repr_str(s: &str) -> String {
588 let has_single = s.contains('\'');
589 let has_double = s.contains('"');
590 let quote = if has_single && !has_double { '"' } else { '\'' };
591
592 let mut out = String::with_capacity(s.len() + 2);
593 out.push(quote);
594 for c in s.chars() {
595 match c {
596 '\\' => out.push_str("\\\\"),
597 c if c == quote => {
598 out.push('\\');
599 out.push(c);
600 }
601 '\n' => out.push_str("\\n"),
602 '\r' => out.push_str("\\r"),
603 '\t' => out.push_str("\\t"),
604 c if (c as u32) < 0x20 || (c as u32) == 0x7f => {
605 out.push_str(&format!("\\x{:02x}", c as u32));
606 }
607 c => out.push(c),
608 }
609 }
610 out.push(quote);
611 out
612}
613
614fn decompress_zlib(data: &[u8]) -> Result<Vec<u8>> {
615 let mut decoder = ZlibDecoder::new(data);
616 let mut decompressed = Vec::new();
617 decoder
618 .read_to_end(&mut decompressed)
619 .context("failed to decompress inventory zlib payload")?;
620 Ok(decompressed)
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 #[test]
628 fn test_inventory_item_creation() {
629 let item = InventoryItem::new(
630 "test_project".to_string(),
631 "1.0".to_string(),
632 "http://example.com/test.html".to_string(),
633 "Test Item".to_string(),
634 );
635
636 assert_eq!(item.project_name, "test_project");
637 assert_eq!(item.project_version, "1.0");
638 assert_eq!(item.uri, "http://example.com/test.html");
639 assert_eq!(item.display_name, "Test Item");
640 }
641
642 #[test]
643 fn test_inventory_operations() {
644 let mut inv = Inventory::new();
645
646 let item = InventoryItem::new(
647 "test".to_string(),
648 "1.0".to_string(),
649 "test.html".to_string(),
650 "Test".to_string(),
651 );
652
653 inv.insert(
654 "py:function".to_string(),
655 "test_func".to_string(),
656 item.clone(),
657 );
658
659 assert!(inv.contains("py:function", "test_func"));
660 assert_eq!(inv.get("py:function", "test_func"), Some(&item));
661 assert!(!inv.contains("py:function", "nonexistent"));
662 }
663
664 #[test]
665 fn test_escape_string() {
666 assert_eq!(
667 InventoryFile::escape_string("test multiple spaces"),
668 "test multiple spaces"
669 );
670 assert_eq!(InventoryFile::escape_string("test\ttab"), "test tab");
671 assert_eq!(
672 InventoryFile::escape_string("test\nnewline"),
673 "test newline"
674 );
675 }
676
677 #[test]
681 fn test_posix_join_inserts_separator() {
682 assert_eq!(posix_join("/util", "foo.html"), "/util/foo.html");
683 }
684
685 #[test]
686 fn test_posix_join_no_double_separator() {
687 assert_eq!(posix_join("/util/", "foo.html"), "/util/foo.html");
688 }
689
690 #[test]
691 fn test_posix_join_empty_location() {
692 assert_eq!(posix_join("/util", ""), "/util/");
693 }
694
695 #[test]
696 fn test_posix_join_empty_uri() {
697 assert_eq!(posix_join("", "foo.html"), "foo.html");
698 }
699
700 #[test]
701 fn test_posix_join_absolute_location_overrides_uri() {
702 assert_eq!(posix_join("/util", "/abs/path.html"), "/abs/path.html");
703 }
704
705 #[test]
706 fn test_posix_join_both_empty() {
707 assert_eq!(posix_join("", ""), "");
708 }
709
710 #[test]
711 fn test_posix_join_uri_with_scheme() {
712 assert_eq!(
713 posix_join("https://example.org/v1", "sub/x.html#y"),
714 "https://example.org/v1/sub/x.html#y"
715 );
716 }
717
718 #[test]
721 fn test_splitlines_mixed_separators() {
722 assert_eq!(
723 python_str_splitlines("a\r\nb\rc\u{0b}d\u{0c}e"),
724 vec!["a", "b", "c", "d", "e"]
725 );
726 }
727
728 #[test]
729 fn test_splitlines_no_trailing_empty() {
730 assert_eq!(python_str_splitlines("a\nb\n"), vec!["a", "b"]);
731 }
732
733 #[test]
734 fn test_splitlines_empty_string() {
735 assert!(python_str_splitlines("").is_empty());
736 }
737
738 #[test]
739 fn test_splitlines_lone_newline() {
740 assert_eq!(python_str_splitlines("\n"), vec![""]);
741 }
742
743 #[test]
744 fn test_splitlines_embedded_blank_line() {
745 assert_eq!(python_str_splitlines("a\n\nb"), vec!["a", "", "b"]);
746 }
747
748 #[test]
751 fn test_split_none_maxsplit_collapses_runs() {
752 assert_eq!(
753 python_split_none_maxsplit("module mod foo.html", 2),
754 vec!["module", "mod", "foo.html"]
755 );
756 }
757
758 #[test]
759 fn test_split_none_maxsplit_remainder_keeps_internal_whitespace() {
760 assert_eq!(
761 python_split_none_maxsplit("a b c d e", 2),
762 vec!["a", "b", "c d e"]
763 );
764 }
765
766 #[test]
767 fn test_split_none_maxsplit_empty() {
768 assert!(python_split_none_maxsplit("", 2).is_empty());
769 assert!(python_split_none_maxsplit(" ", 2).is_empty());
770 }
771
772 #[test]
773 fn test_split_none_maxsplit_too_few_tokens() {
774 assert_eq!(python_split_none_maxsplit("onlyone", 2), vec!["onlyone"]);
775 }
776
777 #[test]
780 fn test_python_repr_str_plain() {
781 assert_eq!(python_repr_str("5"), "'5'");
782 }
783
784 #[test]
785 fn test_python_repr_str_prefers_single_quotes() {
786 assert_eq!(python_repr_str("2.5-beta"), "'2.5-beta'");
787 }
788
789 #[test]
790 fn test_python_repr_str_switches_to_double_quotes() {
791 assert_eq!(python_repr_str("it's"), "\"it's\"");
792 }
793
794 #[test]
799 fn test_v2_line_regex_no_match_on_garbage() {
800 assert!(V2_LINE_RE
803 .captures("not a valid entry line at all")
804 .is_none());
805 }
806
807 #[test]
808 fn test_v2_line_regex_captures_five_groups() {
809 let caps = V2_LINE_RE
810 .captures("a term including:colon std:term -1 glossary.html#term -")
811 .unwrap();
812 assert_eq!(&caps[1], "a term including:colon");
813 assert_eq!(&caps[2], "std:term");
814 assert_eq!(&caps[3], "-1");
815 assert_eq!(&caps[4], "glossary.html#term");
816 assert_eq!(&caps[5], "-");
817 }
818}