Skip to main content

ninja/utils/
mod.rs

1//! Utility functions for common operations.
2//!
3//! Provides helper functions for:
4//!
5//! - [`download`]: Downloading files from URLs with progress tracking
6//! - File operations: Creating tar.gz archives, loading Shurikens from disk
7//! - Process management: Killing processes by PID or name
8//! - Port detection: Finding which process is using a given port
9//! - Configuration parsing: Extracting ports from Apache/Nginx configs
10
11pub mod download;
12
13use crate::{
14    common::types::{FieldValue, ShurikenState},
15    shuriken::{Shuriken, ShurikenConfig},
16};
17use anyhow::{Error, Result, anyhow};
18use flate2::{Compression, write::GzEncoder};
19use regex::Regex;
20use std::{
21    collections::HashMap,
22    fs,
23    path::{Path, PathBuf},
24    sync::Arc,
25};
26use tar::Builder as TarBuilder;
27use tokio::{fs as async_fs, sync::Mutex};
28
29pub fn get_http_port() -> Result<u16> {
30    let apache_conf = "shurikens/Apache/conf/httpd.conf";
31    let nginx_conf = "shurikens/Nginx/nginx.conf";
32
33    if Path::new(apache_conf).exists() {
34        return parse_apache_port(apache_conf);
35    }
36
37    if Path::new(nginx_conf).exists() {
38        return parse_nginx_port(nginx_conf);
39    }
40
41    Err(anyhow!("No Apache or Nginx shuriken found in ./shurikens"))
42}
43
44fn parse_apache_port(path: &str) -> Result<u16> {
45    let file = fs::read_to_string(path)?;
46    let re = Regex::new(r#"(?mi)^\s*Listen\s+([0-9]+)"#)?;
47
48    if let Some(cap) = re.captures(&file) {
49        return Ok(cap[1].parse()?);
50    }
51
52    Err(anyhow!(
53        "Apache config exists but contains no Listen directive"
54    ))
55}
56
57fn parse_nginx_port(path: &str) -> Result<u16> {
58    let file = fs::read_to_string(path)?;
59    let re = Regex::new(r#"(?mi)^\s*listen\s+([0-9]+)"#)?;
60
61    if let Some(cap) = re.captures(&file) {
62        return Ok(cap[1].parse()?);
63    }
64
65    Err(anyhow!(
66        "Nginx config exists but contains no listen directive"
67    ))
68}
69
70pub fn resolve_path(virtual_cwd: &Path, path: &PathBuf) -> PathBuf {
71    let p = Path::new(path);
72
73    if p.is_absolute() {
74        p.to_path_buf()
75    } else {
76        virtual_cwd.join(p)
77    }
78}
79
80#[cfg(windows)]
81pub fn kill_process_by_pid(pid: u32) -> bool {
82    use windows::Win32::Foundation::CloseHandle;
83    use windows::Win32::System::Threading::{
84        OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_TERMINATE, TerminateProcess,
85    };
86
87    unsafe {
88        let query_handle = match OpenProcess(PROCESS_QUERY_INFORMATION, false, pid) {
89            Ok(h) => h,
90            Err(_) => return false,
91        };
92
93        if query_handle.is_invalid() {
94            return false;
95        }
96
97        let _ = CloseHandle(query_handle);
98
99        let terminate_handle = match OpenProcess(PROCESS_TERMINATE, false, pid) {
100            Ok(h) => h,
101            Err(_) => return false,
102        };
103
104        if terminate_handle.is_invalid() {
105            return false;
106        }
107
108        let result = TerminateProcess(terminate_handle, 1).is_ok();
109        let _ = CloseHandle(terminate_handle);
110        result
111    }
112}
113
114#[cfg(windows)]
115pub fn kill_process_by_name(name: &str) -> bool {
116    use std::ffi::OsString;
117    use std::os::windows::ffi::OsStringExt;
118    use windows::Win32::Foundation::{CloseHandle, HANDLE};
119    use windows::Win32::System::Diagnostics::ToolHelp::{
120        CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
121        TH32CS_SNAPPROCESS,
122    };
123
124    let target = name.to_ascii_lowercase();
125
126    unsafe {
127        let snapshot = match CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) {
128            Ok(s) => s,
129            Err(_) => return false,
130        };
131
132        if snapshot.is_invalid() {
133            return false;
134        }
135
136        let mut entry = PROCESSENTRY32W::default();
137        let mut any_killed = false;
138
139        if Process32FirstW(snapshot, &mut entry).is_ok() {
140            loop {
141                let len = entry
142                    .szExeFile
143                    .iter()
144                    .position(|&c| c == 0)
145                    .unwrap_or(entry.szExeFile.len());
146
147                let exe_os: OsString = OsString::from_wide(&entry.szExeFile[..len]);
148                let exe = exe_os.to_string_lossy().to_string();
149
150                if exe.to_ascii_lowercase() == target {
151                    if kill_process_by_pid(entry.th32ProcessID) {
152                        any_killed = true;
153                    }
154                }
155
156                if Process32NextW(snapshot, &mut entry).is_err() {
157                    break;
158                }
159            }
160        }
161
162        let _ = CloseHandle(HANDLE(snapshot.0));
163        any_killed
164    }
165}
166
167#[cfg(unix)]
168pub fn kill_process_by_pid(pid: u32) -> bool {
169    use nix::errno::Errno;
170    use nix::sys::signal::{Signal, kill};
171    use nix::unistd::Pid;
172    use std::thread;
173    use std::time::Duration;
174
175    let pid = Pid::from_raw(pid as i32);
176
177    match kill(pid, None) {
178        Ok(_) => {
179            if kill(pid, Signal::SIGTERM).is_ok() {
180                for _ in 0..10 {
181                    thread::sleep(Duration::from_millis(100));
182                    if kill(pid, None).is_err() {
183                        return true;
184                    }
185                }
186            }
187
188            kill(pid, Signal::SIGKILL).is_ok()
189        }
190        Err(Errno::ESRCH) => false,
191        Err(Errno::EPERM) => kill(pid, Signal::SIGKILL).is_ok(),
192        Err(_) => false,
193    }
194}
195
196#[cfg(unix)]
197pub fn kill_process_by_name(name: &str) -> bool {
198    #[cfg(target_os = "linux")]
199    {
200        use std::fs;
201        use std::io::Read;
202        use std::path::Path;
203
204        let target = name.to_string();
205        let mut any_killed = false;
206
207        if let Ok(entries) = fs::read_dir("/proc") {
208            for entry in entries.flatten() {
209                let file_name = entry.file_name();
210                let file_name_str = match file_name.to_string_lossy().parse::<u32>() {
211                    Ok(_) => file_name.to_string_lossy().to_string(),
212                    Err(_) => continue, // skip non-numeric (not a PID dir)
213                };
214
215                let pid: u32 = match file_name_str.parse() {
216                    Ok(p) => p,
217                    Err(_) => continue,
218                };
219
220                let comm_path = entry.path().join("comm"); // /proc/<pid>/comm
221
222                if !Path::new(&comm_path).exists() {
223                    continue;
224                }
225
226                let mut comm_file = match fs::File::open(&comm_path) {
227                    Ok(f) => f,
228                    Err(_) => continue,
229                };
230
231                let mut comm = String::new();
232                if comm_file.read_to_string(&mut comm).is_err() {
233                    continue;
234                }
235
236                let comm = comm.trim(); // usually just "bash" or "node", etc.
237
238                if comm == target && kill_process_by_pid(pid) {
239                    any_killed = true;
240                }
241            }
242        }
243
244        any_killed
245    }
246
247    // Fallback for macOS / other Unix: use `ps` to find matches
248    #[cfg(not(target_os = "linux"))]
249    {
250        use std::process::Command;
251
252        let target = name.to_string();
253        let output = match Command::new("ps")
254            .args(["-axo", "pid,comm"]) // pid + command name
255            .output()
256        {
257            Ok(o) => o,
258            Err(_) => return false,
259        };
260
261        let stdout = String::from_utf8_lossy(&output.stdout);
262        let mut any_killed = false;
263
264        for line in stdout.lines().skip(1) {
265            // format: "  1234 /usr/bin/myprog"
266            let mut parts = line.split_whitespace();
267            let pid_str = match parts.next() {
268                Some(p) => p,
269                None => continue,
270            };
271            let comm = parts.next().unwrap_or("");
272
273            let pid: u32 = match pid_str.parse() {
274                Ok(p) => p,
275                Err(_) => continue,
276            };
277
278            // Compare just the basename
279            let base = comm.rsplit('/').next().unwrap_or(comm);
280            if base == target {
281                if kill_process_by_pid(pid) {
282                    any_killed = true;
283                }
284            }
285        }
286
287        any_killed
288    }
289}
290
291/// Normalizes a shuriken name to lowercase for consistent directory naming.
292/// This ensures all shuriken directories use lowercase names.
293pub fn normalize_shuriken_name(name: &str) -> String {
294    name.to_lowercase()
295}
296
297pub fn create_tar_gz_bytes(src_dir: &Path) -> Result<Vec<u8>> {
298    if !src_dir.is_dir() {
299        return Err(anyhow::Error::msg(format!(
300            "Source directory does not exist or is not a directory: {}",
301            src_dir.display()
302        )));
303    }
304
305    let mut buf = Vec::new();
306
307    {
308        // Gzip wraps the in-memory buffer
309        let enc = GzEncoder::new(&mut buf, Compression::default());
310        let mut tar = TarBuilder::new(enc);
311
312        // This recursively adds `src_dir` contents under "." in the archive
313        tar.append_dir_all(".", src_dir)?;
314
315        // Finish tar, then finish gzip
316        let enc = tar.into_inner()?; // GzEncoder
317        enc.finish()?; // flush into buf
318    }
319
320    Ok(buf)
321}
322
323// Shared logic for loading shurikens from disk
324pub async fn load_shurikens(root_path: &Path) -> Result<HashMap<String, Shuriken>> {
325    let shurikens_dir = root_path.join("shurikens");
326    let mut shurikens = HashMap::new();
327
328    // Only iterate immediate children of `shurikens/`
329    let mut dir = match async_fs::read_dir(&shurikens_dir).await {
330        Ok(d) => d,
331        Err(_) => return Ok(shurikens), // no shurikens dir = empty
332    };
333
334    while let Some(entry) = dir.next_entry().await? {
335        let shuriken_path = entry.path();
336        if !shuriken_path.is_dir() {
337            continue;
338        }
339
340        let name = match shuriken_path.file_name().and_then(|n| n.to_str()) {
341            Some(n) => n.to_owned(),
342            None => continue, // skip non-UTF8 names
343        };
344
345        let ninja_dir = shuriken_path.join(".ninja");
346
347        // 1. Load manifest (required)
348        let manifest_path = ninja_dir.join("manifest.toml");
349        if !manifest_path.exists() {
350            continue; // not a valid shuriken
351        }
352
353        let content: String = async_fs::read_to_string(&manifest_path).await?;
354
355        let mut shuriken: Shuriken = toml::from_str(&content)
356            .map_err(|e| Error::msg(format!("TOML error in {}: {}", manifest_path.display(), e)))?;
357
358        // 2. Check for lock file
359        let lock_path = ninja_dir.join("shuriken.lck");
360        let state = if lock_path.exists() {
361            ShurikenState::Running
362        } else {
363            ShurikenState::Idle
364        };
365
366        shuriken.state = Arc::new(Mutex::new(state.clone()));
367
368        // 3. Load options (optional)
369        let options_path = ninja_dir.join("options.toml");
370        if options_path.exists() {
371            let content: String = async_fs::read_to_string(&options_path).await?;
372            let options: HashMap<String, FieldValue> = toml::from_str(&content).map_err(|e| {
373                Error::msg(format!(
374                    "Options error in {}: {}",
375                    options_path.display(),
376                    e
377                ))
378            })?;
379
380            if let Some(config) = &mut shuriken.config {
381                config.options = Some(options);
382            } else {
383                shuriken.config = Some(ShurikenConfig {
384                    config_path: PathBuf::from("options.toml"),
385                    options: Some(options),
386                });
387            }
388        }
389
390        // Store using the directory name (which should already be lowercase)
391        // but normalize it to be sure
392        let normalized_name = normalize_shuriken_name(&name);
393        shurikens.insert(normalized_name.clone(), shuriken);
394    }
395
396    Ok(shurikens)
397}
398
399pub struct PortOwner {
400    pub pid: u32,
401    pub name: Option<String>,
402}
403
404#[cfg(target_os = "linux")]
405pub fn get_port_owner(port: u16) -> Option<PortOwner> {
406    use std::fs;
407
408    let content = fs::read_to_string("/proc/net/tcp").ok()?;
409
410    let port_hex = format!("{:04X}", port);
411
412    for line in content.lines().skip(1) {
413        let cols: Vec<_> = line.split_whitespace().collect();
414        if cols.len() < 10 {
415            continue;
416        }
417
418        let local_address = cols[1]; // IP:PORT in hex
419        if local_address.ends_with(&port_hex) {
420            let inode = cols[9];
421
422            // map inode → pid via /proc/*/fd
423            if let Some(pid) = find_pid_by_inode(inode) {
424                return Some(PortOwner { pid, name: None });
425            }
426        }
427    }
428
429    None
430}
431
432#[cfg(target_os = "linux")]
433fn find_pid_by_inode(inode: &str) -> Option<u32> {
434    use std::fs;
435
436    for entry in fs::read_dir("/proc").ok()? {
437        let entry = entry.ok()?;
438        let pid_str = entry.file_name().to_string_lossy().to_string();
439
440        if !pid_str.chars().all(|c| c.is_ascii_digit()) {
441            continue;
442        }
443
444        let fd_path = format!("/proc/{pid_str}/fd");
445
446        if let Ok(fds) = fs::read_dir(fd_path) {
447            for fd in fds.flatten() {
448                if let Ok(link) = fs::read_link(fd.path()) {
449                    if link.to_string_lossy().contains(inode) {
450                        return pid_str.parse().ok();
451                    }
452                }
453            }
454        }
455    }
456
457    None
458}
459
460#[cfg(target_os = "macos")]
461pub fn get_port_owner(port: u16) -> Option<PortOwner> {
462    use std::process::Command;
463
464    let output = Command::new("netstat")
465        .args(["-anv", "-p", "tcp"])
466        .output()
467        .ok()?;
468
469    let text = String::from_utf8_lossy(&output.stdout);
470
471    for line in text.lines() {
472        if line.contains(&format!(".{}", port)) {
473            // macOS does not reliably expose PID here without root
474            return Some(PortOwner { pid: 0, name: None });
475        }
476    }
477
478    None
479}
480
481#[cfg(target_os = "windows")]
482pub fn get_port_owner(port: u16) -> Option<PortOwner> {
483    use windows::Win32::NetworkManagement::IpHelper::*;
484    use windows::Win32::Foundation::*;
485
486    unsafe {
487        let mut size = 0u32;
488
489        GetExtendedTcpTable(
490            std::ptr::null_mut(),
491            &mut size,
492            false.into(),
493            AF_INET.0 as u32,
494            TCP_TABLE_OWNER_PID_ALL,
495            0,
496        );
497
498        let mut buffer = vec![0u8; size as usize];
499
500        let res = GetExtendedTcpTable(
501            buffer.as_mut_ptr() as *mut _,
502            &mut size,
503            false.into(),
504            AF_INET.0 as u32,
505            TCP_TABLE_OWNER_PID_ALL,
506            0,
507        );
508
509        if res != NO_ERROR.0 as i32 {
510            return None;
511        }
512
513        let table = buffer.as_ptr() as *const MIB_TCPTABLE_OWNER_PID;
514        let table = &*table;
515
516        let rows = std::slice::from_raw_parts(
517            table.table.as_ptr(),
518            table.dwNumEntries as usize,
519        );
520
521        for row in rows {
522            let local_port = u16::from_be((*row).dwLocalPort as u16);
523
524            if local_port == port {
525                return Some(PortOwner {
526                    pid: row.dwOwningPid,
527                    name: None,
528                });
529            }
530        }
531    }
532
533    None
534}