search.rs
⎇
Raw
1//! Search API: one walk serves both scopes, streamed as SSE.
2
3use crate::common::*;
4use axum::http::StatusCode;
5
6/// Root id for the whole-root (".") user root is 1 (first row inserted).
7const ROOT: i64 = 1;
8
9/// The `data:` payloads of an SSE body, in order.
10fn events(body: &str) -> Vec<serde_json::Value> {
11 body.lines()
12 .filter_map(|l| l.strip_prefix("data:"))
13 .map(|d| serde_json::from_str(d.trim()).expect("event is JSON"))
14 .collect()
15}
16
17#[tokio::test]
18async fn both_scopes_stream_from_one_walk() {
19 let env = Env::new().await;
20 let admin = env.admin().await;
21 let r = admin
22 .get(&format!("/api/search?q=hello&scope=both&root={ROOT}"))
23 .await;
24 assert_eq!(r.status, StatusCode::OK);
25 assert_eq!(
26 r.header("content-type").as_deref(),
27 Some("text/event-stream")
28 );
29 assert_eq!(r.header("x-accel-buffering").as_deref(), Some("no"));
30
31 let evs = events(&r.text());
32 // The name hit: matched on the entry's own name, with the server's
33 // sniffed kind.
34 let file = evs
35 .iter()
36 .find(|e| e["type"] == "file")
37 .expect("a name hit");
38 assert_eq!(file["path"], "docs/inner/hello.txt");
39 assert_eq!(file["kind"], "text");
40 assert_eq!(file["is_dir"], false);
41 // The content hit, from the same walk.
42 let m = evs
43 .iter()
44 .find(|e| e["type"] == "match")
45 .expect("a content hit");
46 assert_eq!(m["path"], "docs/inner/hello.txt");
47 assert_eq!(m["line"], 1);
48 assert_eq!(m["text"], "hello world");
49 // The stream always ends with the summary.
50 let done = evs.last().expect("a done event");
51 assert_eq!(done["type"], "done");
52 assert_eq!(done["stopped"], false);
53 assert!(done["scanned"].as_u64().unwrap() >= 8);
54}
55
56#[tokio::test]
57async fn path_narrows_the_walk_to_a_subfolder() {
58 let env = Env::new().await;
59 let admin = env.admin().await;
60 let paths = |body: String| -> Vec<String> {
61 events(&body)
62 .iter()
63 .filter(|e| e["type"] == "file")
64 .map(|e| e["path"].as_str().unwrap().to_string())
65 .collect()
66 };
67 // Inside `docs`: the hit is found and its path stays root-relative.
68 let r = admin
69 .get(&format!(
70 "/api/search?q=hello&scope=name&root={ROOT}&path=docs"
71 ))
72 .await;
73 assert_eq!(r.status, StatusCode::OK);
74 assert_eq!(paths(r.text()), vec!["docs/inner/hello.txt".to_string()]);
75 // The start folder itself is not a result.
76 let r = admin
77 .get(&format!(
78 "/api/search?q=docs&scope=name&root={ROOT}&path=docs"
79 ))
80 .await;
81 assert!(paths(r.text()).is_empty());
82 // Outside `docs`: nothing.
83 let r = admin
84 .get(&format!(
85 "/api/search?q=hello&scope=name&root={ROOT}&path=src"
86 ))
87 .await;
88 assert!(paths(r.text()).is_empty());
89 // A missing or escaping start folder is rejected.
90 let r = admin
91 .get(&format!("/api/search?q=hello&root={ROOT}&path=nope"))
92 .await;
93 assert!(r.status.is_client_error());
94 let r = admin
95 .get(&format!("/api/search?q=hello&root={ROOT}&path=../"))
96 .await;
97 assert!(r.status.is_client_error());
98}
99
100#[tokio::test]
101async fn name_scope_ignores_the_parent_path() {
102 let env = Env::new().await;
103 let admin = env.admin().await;
104 let r = admin
105 .get(&format!("/api/search?q=docs&scope=name&root={ROOT}"))
106 .await;
107 let paths: Vec<String> = events(&r.text())
108 .iter()
109 .filter(|e| e["type"] == "file")
110 .map(|e| e["path"].as_str().unwrap().to_string())
111 .collect();
112 // The folder itself, never the files inside it.
113 assert_eq!(paths, vec!["docs".to_string()]);
114}
115
116#[tokio::test]
117async fn search_rejects_bad_input_and_anonymous_callers() {
118 let env = Env::new().await;
119 let admin = env.admin().await;
120 let anon = Client::new(env.app.clone());
121
122 assert_eq!(
123 anon.get("/api/search?q=hello").await.status,
124 StatusCode::UNAUTHORIZED
125 );
126 assert_eq!(
127 admin.get("/api/search?q=%20").await.status,
128 StatusCode::BAD_REQUEST
129 );
130 assert_eq!(
131 admin.get("/api/search?q=hello&scope=nope").await.status,
132 StatusCode::BAD_REQUEST
133 );
134 assert_eq!(
135 admin.get("/api/search?q=hello&root=999").await.status,
136 StatusCode::FORBIDDEN
137 );
138}
139
140#[tokio::test]
141async fn hidden_and_gitignored_entries_are_searched() {
142 let env = Env::new().await;
143 let p = env.root.path();
144 std::fs::create_dir_all(p.join(".hidden")).unwrap();
145 std::fs::write(p.join(".hidden/secretnote.txt"), "needle here").unwrap();
146 std::fs::write(p.join("ignoredfile.log"), "needle here").unwrap();
147 std::fs::write(p.join(".gitignore"), "*.log\n").unwrap();
148 let admin = env.admin().await;
149
150 let r = admin
151 .get(&format!("/api/search?q=needle&scope=content&root={ROOT}"))
152 .await;
153 let evs = events(&r.text());
154 let paths: Vec<String> = evs
155 .iter()
156 .filter_map(|e| e["path"].as_str().map(str::to_string))
157 .collect();
158 assert!(
159 paths.contains(&".hidden/secretnote.txt".to_string()),
160 "{paths:?}"
161 );
162 assert!(paths.contains(&"ignoredfile.log".to_string()), "{paths:?}");
163}
164
165/// An excluded folder is invisible to search: not as a name hit, and not as
166/// a source of content hits from inside it.
167#[tokio::test]
168async fn excluded_folders_never_appear_in_results() {
169 let env = Env::new().await;
170 let admin = env.admin().await;
171
172 // Baseline: "docs" and the file under it are both findable.
173 let r = admin
174 .get(&format!("/api/search?q=hello&scope=both&root={ROOT}"))
175 .await;
176 let paths: Vec<String> = events(&r.text())
177 .iter()
178 .filter(|e| e["type"] == "file")
179 .map(|e| e["path"].as_str().unwrap_or_default().to_string())
180 .collect();
181 assert!(paths.contains(&"docs/inner/hello.txt".to_string()));
182
183 let r = admin
184 .put_json(
185 "/api/admin/settings",
186 &serde_json::json!({
187 "allow_writable_shares": false,
188 // Normalisation: the stored form has no surrounding slashes.
189 "search_excludes": ["/docs/"],
190 }),
191 )
192 .await;
193 assert_eq!(r.status, StatusCode::OK, "settings: {}", r.text());
194 assert_eq!(r.json()["search_excludes"][0], "docs");
195
196 let r = admin
197 .get(&format!("/api/search?q=hello&scope=both&root={ROOT}"))
198 .await;
199 let evs = events(&r.text());
200 for e in &evs {
201 let p = e["path"].as_str().unwrap_or_default();
202 assert!(
203 !p.starts_with("docs"),
204 "excluded folder leaked into results: {e}"
205 );
206 }
207 // A search for the folder's own name finds nothing either.
208 let r = admin
209 .get(&format!("/api/search?q=docs&scope=name&root={ROOT}"))
210 .await;
211 assert!(
212 !events(&r.text()).iter().any(|e| e["type"] == "file"),
213 "the excluded folder itself was still listed"
214 );
215
216 // Everything outside it is untouched.
217 let r = admin
218 .get(&format!("/api/search?q=main&scope=name&root={ROOT}"))
219 .await;
220 assert!(
221 events(&r.text()).iter().any(|e| e["path"] == "src/main.rs"),
222 "an unrelated folder was excluded too"
223 );
224}
225
226/// "." would exclude the whole root, which turns search off rather than
227/// narrowing it. Blank and duplicate entries are dropped the same way.
228#[tokio::test]
229async fn exclude_list_is_normalised() {
230 let env = Env::new().await;
231 let admin = env.admin().await;
232 let r = admin
233 .put_json(
234 "/api/admin/settings",
235 &serde_json::json!({
236 "allow_writable_shares": false,
237 "search_excludes": [".", "", " ", "docs", "docs/", "/src"],
238 }),
239 )
240 .await;
241 assert_eq!(r.status, StatusCode::OK);
242 let got = r.json();
243 let list: Vec<String> = got["search_excludes"]
244 .as_array()
245 .unwrap()
246 .iter()
247 .map(|v| v.as_str().unwrap().to_string())
248 .collect();
249 assert_eq!(list, vec!["docs".to_string(), "src".to_string()]);
250
251 // And it survives a round trip.
252 let r = admin.get("/api/admin/settings").await;
253 assert_eq!(r.json()["search_excludes"], got["search_excludes"]);
254}
255
256/// Windows-style separators must survive normalisation. Trimming the
257/// slashes before converting the backslashes left `\docs\` stored as
258/// `/docs/`, which then matched nothing.
259#[tokio::test]
260async fn backslash_paths_are_normalised_before_trimming() {
261 let env = Env::new().await;
262 let admin = env.admin().await;
263 let r = admin
264 .put_json(
265 "/api/admin/settings",
266 &serde_json::json!({
267 "allow_writable_shares": false,
268 "search_excludes": ["\\docs\\"],
269 }),
270 )
271 .await;
272 assert_eq!(r.status, StatusCode::OK);
273 assert_eq!(r.json()["search_excludes"][0], "docs");
274
275 // And it actually excludes.
276 let r = admin
277 .get(&format!("/api/search?q=hello&scope=both&root={ROOT}"))
278 .await;
279 for e in events(&r.text()) {
280 let p = e["path"].as_str().unwrap_or_default();
281 assert!(!p.starts_with("docs"), "not excluded: {e}");
282 }
283}
284