flow_application/
ansible_import.rs1use std::collections::BTreeMap;
29use std::fs::File;
30use std::io::Read;
31use std::path::Path;
32
33use flate2::read::GzDecoder;
34use serde::{Deserialize, Serialize};
35use thiserror::Error;
36
37const MAX_ENTRIES: usize = 5000;
41const MAX_UNCOMPRESSED_BYTES: u64 = 256 * 1024 * 1024;
45
46#[derive(Debug, Error)]
47pub enum ImportError {
48 #[error("io: {0}")]
49 Io(String),
50 #[error("invalid archive: {0}")]
51 InvalidArchive(String),
52 #[error("missing or malformed MANIFEST.json: {0}")]
53 BadManifest(String),
54 #[error("archive too large: {0}")]
55 TooLarge(String),
56}
57
58#[derive(Debug, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct CollectionPreview {
61 pub namespace: String,
62 pub name: String,
63 pub version: String,
64 pub modules: Vec<ModulePreview>,
66}
67
68#[derive(Debug, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct ModulePreview {
71 pub fqcn: String,
73 pub short_name: String,
75 pub cli_command: Option<CliCommand>,
79 pub examples: Option<String>,
83}
84
85#[derive(Debug, Serialize, Deserialize, Clone)]
86#[serde(rename_all = "camelCase")]
87pub struct CliCommand {
88 pub binary: String,
92 pub subcommand: String,
96}
97
98pub fn import_collection(tar_path: &Path) -> Result<CollectionPreview, ImportError> {
102 let file = File::open(tar_path)
103 .map_err(|e| ImportError::Io(format!("open {}: {e}", tar_path.display())))?;
104 let decoder = GzDecoder::new(file);
105 let mut archive = tar::Archive::new(decoder);
106
107 let mut manifest: Option<RawManifest> = None;
108 let mut module_names: Vec<String> = Vec::new();
109 let mut module_sources: BTreeMap<String, String> = BTreeMap::new();
110 let mut action_sources: BTreeMap<String, String> = BTreeMap::new();
111 let mut entry_count = 0usize;
112 let mut total_bytes = 0u64;
113
114 for entry in archive
115 .entries()
116 .map_err(|e| ImportError::InvalidArchive(format!("read entries: {e}")))?
117 {
118 let mut entry =
119 entry.map_err(|e| ImportError::InvalidArchive(format!("bad entry: {e}")))?;
120
121 entry_count += 1;
122 if entry_count > MAX_ENTRIES {
123 return Err(ImportError::TooLarge(format!(
124 "more than {MAX_ENTRIES} entries"
125 )));
126 }
127
128 let size = entry.header().size().unwrap_or(0);
129 total_bytes = total_bytes.saturating_add(size);
130 if total_bytes > MAX_UNCOMPRESSED_BYTES {
131 return Err(ImportError::TooLarge(format!(
132 "uncompressed bytes exceed {MAX_UNCOMPRESSED_BYTES}"
133 )));
134 }
135
136 let path = entry
137 .path()
138 .map_err(|e| ImportError::InvalidArchive(format!("entry path: {e}")))?
139 .into_owned();
140 let path_str = path.to_string_lossy();
141 if path_str.contains("..") || path.is_absolute() {
142 return Err(ImportError::InvalidArchive(format!(
143 "unsafe path: {path_str}"
144 )));
145 }
146
147 if path_str == "MANIFEST.json" {
148 let mut buf = String::new();
149 entry
150 .read_to_string(&mut buf)
151 .map_err(|e| ImportError::Io(format!("read MANIFEST.json: {e}")))?;
152 manifest = Some(
153 serde_json::from_str(&buf)
154 .map_err(|e| ImportError::BadManifest(e.to_string()))?,
155 );
156 continue;
157 }
158
159 if let Some(name) = strip_prefix_and_py(&path_str, "plugins/modules/") {
160 let mut buf = String::new();
161 entry
162 .read_to_string(&mut buf)
163 .map_err(|e| ImportError::Io(format!("read module {name}: {e}")))?;
164 module_names.push(name.clone());
165 module_sources.insert(name, buf);
166 continue;
167 }
168
169 if let Some(name) = strip_prefix_and_py(&path_str, "plugins/action/") {
170 let mut buf = String::new();
171 entry
172 .read_to_string(&mut buf)
173 .map_err(|e| ImportError::Io(format!("read action {name}: {e}")))?;
174 action_sources.insert(name, buf);
175 continue;
176 }
177 }
178
179 let manifest = manifest.ok_or_else(|| {
180 ImportError::BadManifest("MANIFEST.json not present in archive".into())
181 })?;
182 let info = manifest.collection_info;
183
184 module_names.sort();
185 module_names.dedup();
186
187 let modules = module_names
188 .into_iter()
189 .map(|short| {
190 let cli = action_sources
191 .get(&short)
192 .and_then(|src| detect_cli_command(src));
193 let examples = module_sources
194 .get(&short)
195 .and_then(|src| extract_examples_block(src));
196 ModulePreview {
197 fqcn: format!("{}.{}.{}", info.namespace, info.name, short),
198 short_name: short,
199 cli_command: cli,
200 examples,
201 }
202 })
203 .collect();
204
205 Ok(CollectionPreview {
206 namespace: info.namespace,
207 name: info.name,
208 version: info.version,
209 modules,
210 })
211}
212
213#[derive(Debug, Deserialize)]
214struct RawManifest {
215 collection_info: RawCollectionInfo,
216}
217
218#[derive(Debug, Deserialize)]
219struct RawCollectionInfo {
220 namespace: String,
221 name: String,
222 version: String,
223}
224
225fn strip_prefix_and_py(path: &str, prefix: &str) -> Option<String> {
226 let rest = path.strip_prefix(prefix)?;
227 let stem = rest.strip_suffix(".py")?;
228 if stem.is_empty() || stem.contains('/') {
229 return None;
230 }
231 if stem == "__init__" {
232 return None;
233 }
234 Some(stem.to_string())
235}
236
237fn detect_cli_command(source: &str) -> Option<CliCommand> {
245 use regex::Regex;
246 let zowe = Regex::new(r#"(?m)^\s*zowe_command\s*=\s*["']([^"']+)["']"#).ok()?;
248 if let Some(cap) = zowe.captures(source) {
249 let subcommand = cap.get(1).map(|m| m.as_str().trim().to_string())?;
250 if !subcommand.is_empty() {
251 return Some(CliCommand {
252 binary: "zowe".into(),
253 subcommand,
254 });
255 }
256 }
257 let cmd = Regex::new(r#"(?m)^\s*command_to_run\s*=\s*["']([^"']+)["']"#).ok()?;
261 if let Some(cap) = cmd.captures(source) {
262 let line = cap.get(1).map(|m| m.as_str().trim().to_string())?;
263 if let Some((bin, rest)) = line.split_once(char::is_whitespace) {
264 return Some(CliCommand {
265 binary: bin.to_string(),
266 subcommand: rest.trim().to_string(),
267 });
268 }
269 return Some(CliCommand {
270 binary: line,
271 subcommand: String::new(),
272 });
273 }
274 None
275}
276
277fn extract_examples_block(source: &str) -> Option<String> {
283 use regex::Regex;
284 let triples: [(&str, &str); 2] = [
288 (r#"(?s)EXAMPLES\s*=\s*r?'''(.*?)'''"#, ""),
289 (r#"(?s)EXAMPLES\s*=\s*r?"""(.*?)""""#, ""),
290 ];
291 for (pat, _) in triples {
292 if let Ok(re) = Regex::new(pat) {
293 if let Some(cap) = re.captures(source) {
294 if let Some(body) = cap.get(1) {
295 let s = body.as_str().trim();
296 if !s.is_empty() {
297 return Some(s.to_string());
298 }
299 }
300 }
301 }
302 }
303 None
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn detects_zowe_command() {
312 let src = r#"
313def run(self):
314 zowe_command = "dbm-db2 deploy ddl"
315 return zowe_command
316"#;
317 let got = detect_cli_command(src).expect("should detect");
318 assert_eq!(got.binary, "zowe");
319 assert_eq!(got.subcommand, "dbm-db2 deploy ddl");
320 }
321
322 #[test]
323 fn detects_command_to_run_with_binary() {
324 let src = r#"command_to_run = "pwsh -Command Get-Process""#;
325 let got = detect_cli_command(src).expect("should detect");
326 assert_eq!(got.binary, "pwsh");
327 assert_eq!(got.subcommand, "-Command Get-Process");
328 }
329
330 #[test]
331 fn no_match_for_dynamic_command() {
332 let src = r#"zowe_command = f"dbm-db2 {action}""#;
333 assert!(detect_cli_command(src).is_none());
334 }
335
336 #[test]
337 fn extracts_raw_triple_single_examples() {
338 let src = r#"
339DOCUMENTATION = '''---
340module: do_thing
341'''
342
343EXAMPLES = r'''
344- name: Do the thing
345 ns.coll.do_thing:
346 target: alpha
347'''
348
349RETURN = '''
350data: ...
351'''
352"#;
353 let got = extract_examples_block(src).expect("should extract");
354 assert!(got.starts_with("- name: Do the thing"));
355 assert!(got.contains("target: alpha"));
356 assert!(!got.contains("RETURN"));
357 }
358
359 #[test]
360 fn extracts_triple_double_examples() {
361 let src = r#"
362EXAMPLES = """
363- name: Hi
364 some.mod.x:
365 a: 1
366"""
367"#;
368 let got = extract_examples_block(src).expect("should extract");
369 assert!(got.contains("a: 1"));
370 }
371
372 #[test]
373 fn no_examples_returns_none() {
374 let src = r#"DOCUMENTATION = '''m'''"#;
375 assert!(extract_examples_block(src).is_none());
376 }
377
378 #[test]
379 fn strip_prefix_rejects_init_and_nested() {
380 assert_eq!(
381 strip_prefix_and_py("plugins/modules/copy.py", "plugins/modules/"),
382 Some("copy".into())
383 );
384 assert_eq!(
385 strip_prefix_and_py("plugins/modules/__init__.py", "plugins/modules/"),
386 None
387 );
388 assert_eq!(
389 strip_prefix_and_py("plugins/modules/sub/copy.py", "plugins/modules/"),
390 None
391 );
392 }
393}