1mod list_endpoints;
8mod value_lists;
9pub use list_endpoints::{
10 ListFilter, create_item, create_list, delete_item, delete_list, export_lists, find_items,
11 find_lists, get_list, import_lists, resolve_ids, update_item, update_list,
12};
13pub(crate) use value_lists::{ValueListRef, value_list_exists, value_lists_bootstrapped};
14
15use crate::codec::{self, Bundle, Format};
16use crate::model::{ExceptionItem, ExceptionList, ListKey};
17use crate::normalize;
18use crate::ops::{DeleteOutcome, ExportOutcome, ImportPlan, ImportReport, MutationPlan};
19use elasticctl_core::{Error, ErrorKind, Result, Transport};
20use serde::Serialize;
21use serde_json::{Value, json};
22use std::collections::BTreeSet;
23use std::path::Path;
24
25const NAMESPACES: [&str; 2] = ["single", "agnostic"];
29
30#[derive(Debug, Clone, PartialEq, Serialize)]
32pub struct ListReport {
33 pub total: usize,
34 pub lists: Vec<ExceptionList>,
35}
36
37#[derive(Debug, Clone, PartialEq, Serialize)]
39pub struct ListDetail {
40 pub list: ExceptionList,
41 pub items: Vec<ExceptionItem>,
42}
43
44fn namespaces_to_search(namespace: Option<&str>) -> Vec<&str> {
47 match namespace {
48 Some(ns) => vec![ns],
49 None => NAMESPACES.to_vec(),
50 }
51}
52
53async fn resolve_list_key(
61 t: &Transport,
62 list_id: &str,
63 namespace: Option<&str>,
64) -> Result<ListKey> {
65 if let Some(ns) = namespace {
66 let key = ListKey {
67 list_id: list_id.to_string(),
68 namespace_type: ns.to_string(),
69 };
70 match get_list(t, &key).await {
71 Ok(_) => return Ok(key),
72 Err(e) if e.kind == ErrorKind::NotFound => {
74 return Err(Error::new(
75 ErrorKind::NotFound,
76 format!("exception list not found: {list_id} ({ns})"),
77 ));
78 }
79 Err(e) => return Err(e),
80 }
81 }
82
83 let mut matches = Vec::new();
84 for ns in NAMESPACES {
85 let key = ListKey {
86 list_id: list_id.to_string(),
87 namespace_type: ns.to_string(),
88 };
89 match get_list(t, &key).await {
90 Ok(_) => matches.push(key),
91 Err(e) if e.kind == ErrorKind::NotFound => {}
92 Err(e) => return Err(e),
93 }
94 }
95 match matches.len() {
96 1 => Ok(matches.pop().expect("one match")),
97 0 => Err(Error::new(
98 ErrorKind::NotFound,
99 format!("exception list not found: {list_id}"),
100 )),
101 _ => Err(Error::new(
102 ErrorKind::Conflict,
103 format!(
104 "exception list '{list_id}' exists in both the 'single' and 'agnostic' \
105 namespaces; pass --namespace to select one"
106 ),
107 )),
108 }
109}
110
111async fn all_list_keys(t: &Transport, namespace: Option<&str>) -> Result<Vec<ListKey>> {
113 let mut keys = Vec::new();
114 for ns in namespaces_to_search(namespace) {
115 let filter = ListFilter {
116 namespace: Some(ns.to_string()),
117 ..Default::default()
118 };
119 for list in find_lists(t, &filter).await? {
120 keys.push(list.key()?);
121 }
122 }
123 keys.sort();
124 keys.dedup();
125 Ok(keys)
126}
127
128async fn resolve_selection(
132 t: &Transport,
133 selectors: &[String],
134 tag: Option<&str>,
135 namespace: Option<&str>,
136 noun: &str,
137) -> Result<Option<Vec<ListKey>>> {
138 if selectors.is_empty() && tag.is_none() {
139 return Ok(None);
140 }
141
142 let mut keys = Vec::new();
143 for s in selectors {
144 keys.push(resolve_list_key(t, s, namespace).await?);
145 }
146
147 let mut tag_matched = false;
148 if let Some(tag) = tag {
149 for ns in namespaces_to_search(namespace) {
150 let filter = ListFilter {
151 tag: Some(tag.to_string()),
152 namespace: Some(ns.to_string()),
153 ..Default::default()
154 };
155 for list in find_lists(t, &filter).await? {
156 tag_matched = true;
157 keys.push(list.key()?);
158 }
159 }
160 if !tag_matched {
161 return Err(Error::new(
162 ErrorKind::NotFound,
163 format!("No exception lists matched tag '{tag}'; nothing to {noun}"),
164 ));
165 }
166 }
167
168 keys.sort();
169 keys.dedup();
170 Ok(Some(keys))
171}
172
173pub async fn list_op(t: &Transport, f: &ListFilter) -> Result<ListReport> {
177 let mut lists = Vec::new();
178 if f.namespace.is_some() {
179 lists = find_lists(t, f).await?;
180 } else {
181 for ns in NAMESPACES {
182 let mut per_ns = f.clone();
183 per_ns.namespace = Some(ns.to_string());
184 lists.extend(find_lists(t, &per_ns).await?);
185 }
186 }
187 normalize::sort_lists(&mut lists);
188 let total = lists.len();
189 Ok(ListReport { total, lists })
190}
191
192pub async fn get_op(t: &Transport, list_id: &str, namespace: Option<&str>) -> Result<ListDetail> {
194 let key = resolve_list_key(t, list_id, namespace).await?;
195 let list = get_list(t, &key).await?;
196 let items = find_items(t, &key).await?;
197 Ok(ListDetail { list, items })
198}
199
200pub fn validate_op(path: &Path) -> Result<Bundle> {
205 let body = std::fs::read_to_string(path)
206 .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
207 if Format::from_path(path) == Format::Yaml {
208 return Err(Error::new(
209 ErrorKind::Unsupported,
210 format!(
211 "{} is YAML, which cannot represent exception lists or items; validate an NDJSON bundle",
212 path.display()
213 ),
214 ));
215 }
216 codec::decode_bundle(&body)
217}
218
219pub async fn export_op(
224 t: &Transport,
225 list_ids: &[String],
226 tag: Option<&str>,
227 namespace: Option<&str>,
228 format: Format,
229) -> Result<ExportOutcome> {
230 if format == Format::Yaml {
231 return Err(Error::new(
232 ErrorKind::Unsupported,
233 "exception bundles have no YAML form; re-run with --format-file ndjson",
234 ));
235 }
236 let keys = match resolve_selection(t, list_ids, tag, namespace, "export").await? {
237 Some(keys) => keys,
238 None => all_list_keys(t, namespace).await?,
239 };
240 export_lists(t, &keys).await
241}
242
243#[derive(Debug, Clone, PartialEq)]
246pub struct DeletePlan {
247 pub preview: MutationPlan,
248 pub keys: Vec<ListKey>,
249}
250
251pub async fn plan_delete_op(
256 t: &Transport,
257 list_ids: &[String],
258 namespace: Option<&str>,
259) -> Result<DeletePlan> {
260 let mut resolved = BTreeSet::new();
261 for id in list_ids {
262 resolved.insert(resolve_list_key(t, id, namespace).await?);
263 }
264 let keys: Vec<_> = resolved.into_iter().collect();
265 let targets: Vec<_> = keys
266 .iter()
267 .map(|key| format!("{} ({})", key.list_id, key.namespace_type))
268 .collect();
269 Ok(DeletePlan {
270 preview: MutationPlan {
271 preview_action: format!("Delete {} exception list(s)", targets.len()),
272 preview_details: targets.clone(),
273 targets,
274 },
275 keys,
276 })
277}
278
279pub async fn apply_delete_op(t: &Transport, plan: &DeletePlan) -> Result<DeleteOutcome> {
282 let mut deleted = Vec::new();
283 let mut failed = Vec::new();
284 for key in &plan.keys {
285 match delete_list(t, key).await {
286 Ok(_) => deleted.push(json!({
287 "list_id": key.list_id,
288 "namespace_type": key.namespace_type,
289 })),
290 Err(e) => failed.push(json!({
291 "list_id": key.list_id,
292 "namespace_type": key.namespace_type,
293 "error": e.message,
294 })),
295 }
296 }
297 Ok(DeleteOutcome {
298 applied: true,
299 deleted,
300 failed,
301 total: plan.keys.len(),
302 })
303}
304
305pub async fn plan_import_op(
314 t: Option<&Transport>,
315 path: &Path,
316 overwrite: bool,
317 skip_existing: bool,
318) -> Result<ImportPlan> {
319 let body = std::fs::read_to_string(path)
320 .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
321
322 if Format::from_path(path) == Format::Yaml {
323 return Err(Error::new(
324 ErrorKind::Unsupported,
325 format!(
326 "{} is YAML, which cannot represent exception lists or items; import an NDJSON bundle",
327 path.display()
328 ),
329 ));
330 }
331
332 let bundle = codec::decode_bundle(&body)?;
333
334 if !bundle.rules.is_empty() {
337 return Err(Error::new(
338 ErrorKind::Unsupported,
339 format!(
340 "this file carries {} rule(s), which the exception import route cannot import; \
341 use `rules import` for a rules bundle",
342 bundle.rules.len()
343 ),
344 ));
345 }
346
347 for item in &bundle.items {
351 if item.list_id().is_err() {
352 return Err(Error::new(
353 ErrorKind::Error,
354 format!(
355 "an exception item ('{}') has no readable list_id",
356 item.item_id().unwrap_or("<unreadable>")
357 ),
358 ));
359 }
360 }
361
362 let total = bundle.lists.len() + bundle.items.len();
363 let mut lists = bundle.lists;
364 let mut items = bundle.items;
365 let mut skipped: Vec<Value> = Vec::new();
366
367 if skip_existing {
368 let t = t.ok_or_else(|| {
369 Error::new(ErrorKind::Error, "import --skip-existing needs a transport")
370 })?;
371 let keys: Vec<ListKey> = lists
372 .iter()
373 .map(ExceptionList::key)
374 .collect::<Result<_>>()?;
375 let existing = resolve_ids(t, &keys).await?;
376
377 let mut keep = Vec::with_capacity(lists.len());
378 let mut skip_keys = Vec::new();
379 for list in lists {
380 let key = list.key()?;
381 if existing.contains_key(&key) {
382 skipped.push(json!({
383 "list_id": key.list_id,
384 "namespace_type": key.namespace_type,
385 "reason": "exists",
386 }));
387 skip_keys.push(key);
388 } else {
389 keep.push(list);
390 }
391 }
392 lists = keep;
393
394 if !skip_keys.is_empty() {
397 let skip_set: std::collections::BTreeSet<ListKey> = skip_keys.into_iter().collect();
398 items.retain(|i| {
399 let key = ListKey {
400 list_id: i.list_id().expect("list_id validated above").to_string(),
401 namespace_type: i.namespace_type().to_string(),
402 };
403 !skip_set.contains(&key)
404 });
405 }
406 }
407
408 let mut details = Vec::with_capacity(lists.len() + items.len() + skipped.len());
409 for l in &lists {
410 details.push(format!("{} {} import", l.list_id()?, l.name()));
411 }
412 for i in &items {
413 details.push(format!("{} {} import", i.item_id()?, i.list_id()?));
414 }
415 details.extend(skipped.iter().map(|s| {
416 format!(
417 "{} skip (already exists)",
418 s["list_id"].as_str().unwrap_or_default()
419 )
420 }));
421
422 let mut targets = Vec::with_capacity(lists.len() + items.len());
423 for l in &lists {
424 targets.push(l.list_id()?.to_string());
425 }
426 for i in &items {
427 targets.push(i.item_id()?.to_string());
428 }
429
430 let qualifier = if overwrite {
431 ", overwriting existing".to_string()
432 } else if skip_existing && !skipped.is_empty() {
433 format!(", skipping {} that already exist", skipped.len())
434 } else {
435 String::new()
436 };
437 let preview = MutationPlan {
438 preview_action: format!(
439 "Import {} exception list(s) and {} item(s) from {}{qualifier}",
440 lists.len(),
441 items.len(),
442 path.display()
443 ),
444 preview_details: details,
445 targets,
446 };
447
448 let ndjson = codec::encode_bundle(&Bundle {
452 rules: Vec::new(),
453 lists,
454 items,
455 summary: None,
456 })?;
457
458 Ok(ImportPlan {
459 preview,
460 ndjson,
461 total,
462 skipped,
463 })
464}
465
466pub async fn apply_import_op(t: &Transport, ndjson: &str, overwrite: bool) -> Result<ImportReport> {
468 if ndjson.is_empty() {
470 return Ok(ImportReport {
471 succeeded: json!(0),
472 failed: json!([]),
473 });
474 }
475
476 let response = import_lists(t, ndjson, overwrite).await?;
477 crate::ops::decode_import_report(&response, "exceptions")
478}