1pub fn extract_links(body: &str) -> Vec<String> {
6 use pulldown_cmark::{Event, Parser, Tag};
7
8 let parser = Parser::new(body);
9 let mut links = Vec::new();
10 for event in parser {
11 if let Event::Start(Tag::Link { dest_url, .. }) = event {
12 links.push(dest_url.to_string());
13 }
14 }
15 links
16}
17
18pub fn resolve_link(doc_path: &str, target: &str) -> Option<String> {
23 if target.is_empty()
24 || target.contains("://")
25 || target.starts_with('/')
26 || target.starts_with('#')
27 || has_uri_scheme(target)
28 {
29 return None;
30 }
31
32 let target = match target.split_once('#') {
34 Some((before, _)) => before,
35 None => target,
36 };
37 if target.is_empty() {
38 return None;
39 }
40
41 normalize_relative(doc_path, target)
42}
43
44pub(crate) fn normalize_relative(base_path: &str, target: &str) -> Option<String> {
48 let mut segments: Vec<&str> = {
49 let mut segs: Vec<&str> = base_path.split('/').collect();
50 segs.pop(); segs
52 };
53 for part in target.split('/') {
54 match part {
55 "" | "." => continue,
56 ".." => {
57 segments.pop()?;
58 }
59 other => segments.push(other),
60 }
61 }
62
63 if segments.is_empty() {
64 return None;
65 }
66
67 Some(segments.join("/"))
68}
69
70pub(crate) fn percent_decode(s: &str) -> String {
73 let bytes = s.as_bytes();
74 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
75 let mut i = 0;
76 while i < bytes.len() {
77 if bytes[i] == b'%' && i + 2 < bytes.len() {
78 let hi = (bytes[i + 1] as char).to_digit(16);
79 let lo = (bytes[i + 2] as char).to_digit(16);
80 if let (Some(h), Some(l)) = (hi, lo) {
81 out.push((h * 16 + l) as u8);
82 i += 3;
83 continue;
84 }
85 }
86 out.push(bytes[i]);
87 i += 1;
88 }
89 String::from_utf8(out).unwrap_or_else(|_| s.to_string())
90}
91
92pub(crate) fn has_uri_scheme(s: &str) -> bool {
96 uri_scheme(s).is_some()
97}
98
99pub(crate) fn uri_scheme(s: &str) -> Option<&str> {
101 let mut chars = s.char_indices();
102 match chars.next() {
103 Some((_, c)) if c.is_ascii_alphabetic() => {}
104 _ => return None,
105 }
106 for (i, c) in chars {
107 if c == ':' {
108 return Some(&s[..i]);
109 }
110 if !(c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-') {
111 return None;
112 }
113 }
114 None
115}
116
117pub(crate) fn parent_dir(path: &str) -> &str {
119 path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("")
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
128 fn インライン形式のリンクをdoc_reqエッジとして抽出できる() {
129 let body = "パスワードは [最小長](../req/auth/password-min-length.md) に従う。\n";
130 let links = extract_links(body);
131 assert_eq!(
132 links,
133 vec!["../req/auth/password-min-length.md".to_string()]
134 );
135 let resolved = resolve_link("docs/model.md", &links[0]).unwrap();
136 assert_eq!(resolved, "req/auth/password-min-length.md");
137 }
138
139 #[test]
141 fn 参照形式のリンクをdoc_reqエッジとして抽出できる() {
142 let body =
143 "パスワードは [最小長][ref] に従う。\n\n[ref]: ../req/auth/password-min-length.md\n";
144 let links = extract_links(body);
145 assert_eq!(
146 links,
147 vec!["../req/auth/password-min-length.md".to_string()]
148 );
149 let resolved = resolve_link("docs/model.md", &links[0]).unwrap();
150 assert_eq!(resolved, "req/auth/password-min-length.md");
151 }
152
153 #[test]
155 fn resolve_linkはフラグメントを取り除いてから解決する() {
156 let resolved = resolve_link("docs/model.md", "../req/auth/password-min-length.md#見出し");
157 assert_eq!(
158 resolved,
159 Some("req/auth/password-min-length.md".to_string())
160 );
161 }
162
163 #[test]
165 fn 画像リンクはextract_linksの対象にならない() {
166 let body = "説明図です。\n\n\n";
167 let links = extract_links(body);
168 assert!(links.is_empty(), "image link should be excluded: {links:?}");
169 }
170
171 #[test]
173 fn コードブロックやインラインコード内のリンクは対象にならない() {
174 let body = "インラインコード: `[text](../req/x.md)`\n\n```\n[text](../req/y.md)\n```\n";
175 let links = extract_links(body);
176 assert!(
177 links.is_empty(),
178 "links inside code should be excluded: {links:?}"
179 );
180 }
181
182 #[test]
184 fn 外部urlやリポジトリ外へ逃げるパスはresolve_linkでnoneになる() {
185 assert_eq!(
186 resolve_link("docs/model.md", "https://example.com/req/x.md"),
187 None
188 );
189 assert_eq!(
190 resolve_link("docs/model.md", "http://example.com/req/x.md"),
191 None
192 );
193 assert_eq!(resolve_link("docs/model.md", "../../../etc/passwd"), None);
194 assert_eq!(resolve_link("docs/model.md", "#見出しだけ"), None);
195 }
196
197 #[test]
199 fn mailtoやdataスキームのリンクはresolve_linkでnoneになる() {
200 assert_eq!(
201 resolve_link("docs/model.md", "mailto:someone@example.com"),
202 None
203 );
204 assert_eq!(
205 resolve_link("docs/model.md", "data:text/plain;base64,aGVsbG8="),
206 None
207 );
208 }
209
210 #[test]
212 fn percent_decodeは通常の列をutf8としてデコードする() {
213 assert_eq!(percent_decode("%E9%9B%86.md"), "集.md");
214 assert_eq!(percent_decode("a%20b"), "a b");
215 assert_eq!(percent_decode("a%23b"), "a#b");
216 assert_eq!(percent_decode("x%41"), "xA");
217 assert_eq!(percent_decode("plain"), "plain");
218 }
219
220 #[test]
222 fn percent_decodeは不正な列をそのまま残す() {
223 assert_eq!(percent_decode("100%"), "100%");
224 assert_eq!(percent_decode("%zz"), "%zz");
225 assert_eq!(percent_decode("a%2"), "a%2");
226 assert_eq!(percent_decode("%E9"), "%E9");
227 }
228
229 #[test]
231 fn normalize_relativeはresolve_linkと同じ正規化をする() {
232 let cases = [
233 ("docs/index.md", "a.md"),
234 ("docs/index.md", "./a/../b.md"),
235 ("docs/sub/index.md", "../a.md"),
236 ("docs/index.md", "sub//x.md"),
237 ("docs/index.md", "../../out.md"),
238 ("docs/index.md", "../.."),
239 ];
240 for (base, target) in cases {
241 assert_eq!(
242 normalize_relative(base, target),
243 resolve_link(base, target),
244 "{base} {target}"
245 );
246 }
247 assert_eq!(
248 normalize_relative("docs/sub/index.md", "../a.md"),
249 Some("docs/a.md".to_string())
250 );
251 assert_eq!(normalize_relative("docs/index.md", "../../out.md"), None);
252 }
253}