Skip to main content

ninja/utils/
mod.rs

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