use std::collections::HashSet;
use std::ffi::OsStr;
use std::io::{self, Write};
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use crate::index::Index;
use crate::Config;
#[cfg(unix)]
fn resolve_git_binary() -> PathBuf {
if let Ok(path_var) = std::env::var("PATH") {
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join("git");
if candidate.is_file() {
if let Ok(resolved) = candidate.canonicalize() {
return resolved;
}
}
}
}
PathBuf::from("/usr/bin/git")
}
#[cfg(unix)]
fn is_safe_git_path(path: &Path) -> bool {
if path.as_os_str().is_empty() {
return false;
}
use std::path::Component;
for component in path.components() {
match component {
Component::ParentDir | Component::RootDir | Component::Prefix(_) => return false,
_ => {}
}
}
true
}
pub(super) fn cmd_index(mut config: Config, _force: bool, stats: bool, quiet: bool) -> i32 {
if quiet {
config.verbose = false;
} else if !config.verbose {
config.verbose = true;
}
let index = match Index::build(config) {
Ok(idx) => idx,
Err(e) => {
eprintln!("st index: {e}");
return 2;
}
};
if stats {
let s = index.stats();
let stdout = io::stdout();
let mut out = stdout.lock();
if let Err(err) = writeln!(out, "Documents: {}", s.total_documents)
.and_then(|_| writeln!(out, "Segments: {}", s.total_segments))
.and_then(|_| writeln!(out, "Grams: {}", s.total_grams))
{
return handle_output(err);
}
}
drop(index);
0
}
pub(super) fn cmd_status(config: Config, json: bool) -> i32 {
let index = match Index::open(config.clone()) {
Ok(idx) => idx,
Err(e) => {
eprintln!("st status: {e}");
return 2;
}
};
let s = index.stats();
if json {
let obj = serde_json::json!({
"documents": s.total_documents,
"segments": s.total_segments,
"grams": s.total_grams,
"index_dir": config.index_dir.display().to_string(),
});
let stdout = io::stdout();
let mut out = stdout.lock();
if let Err(err) = writeln!(out, "{obj}") {
return handle_output(err);
}
} else {
let stdout = io::stdout();
let mut out = stdout.lock();
if let Err(err) = writeln!(out, "Index: {}", config.index_dir.display())
.and_then(|_| writeln!(out, "Documents: {}", s.total_documents))
.and_then(|_| writeln!(out, "Segments: {}", s.total_segments))
.and_then(|_| writeln!(out, "Grams: {}", s.total_grams))
{
return handle_output(err);
}
if let Some(ref commit) = s.base_commit {
if let Err(err) = writeln!(out, "Commit: {commit}") {
return handle_output(err);
}
}
}
0
}
pub(super) fn cmd_update(config: Config, _flush: bool, quiet: bool) -> i32 {
let index = match Index::open(config.clone()) {
Ok(idx) => idx,
Err(e) => {
eprintln!("st update: {e}");
return 2;
}
};
let git = resolve_git_binary();
let mut changed: HashSet<PathBuf> = HashSet::new();
let canonical_root = match config.repo_root.canonicalize() {
Ok(p) => p,
Err(e) => {
eprintln!(
"st update: invalid repo root \'{}\': {e}",
config.repo_root.display()
);
return 2;
}
};
let parse_nul_paths = |bytes: &[u8]| -> Vec<PathBuf> {
bytes
.split(|&b| b == 0)
.map(|s| PathBuf::from(OsStr::from_bytes(s)))
.filter(|path| is_safe_git_path(path))
.collect()
};
if let Ok(diff_output) = std::process::Command::new(&git)
.arg("-C")
.arg(&canonical_root)
.args(["diff", "-z", "--name-only", "HEAD"])
.output()
{
if diff_output.status.success() {
changed.extend(parse_nul_paths(&diff_output.stdout));
}
}
if let Ok(staged_output) = std::process::Command::new(&git)
.arg("-C")
.arg(&canonical_root)
.args(["diff", "-z", "--name-only", "--cached"])
.output()
{
if staged_output.status.success() {
changed.extend(parse_nul_paths(&staged_output.stdout));
}
}
if let Ok(ut_output) = std::process::Command::new(&git)
.arg("-C")
.arg(&canonical_root)
.args(["ls-files", "-z", "--others", "--exclude-standard"])
.output()
{
if ut_output.status.success() {
changed.extend(parse_nul_paths(&ut_output.stdout));
}
}
if changed.is_empty() {
if !quiet {
let stdout = io::stdout();
let mut out = stdout.lock();
if let Err(err) = writeln!(out, "st: no changes detected") {
return handle_output(err);
}
}
return 0;
}
let mut count = 0;
let mut notify_errors = 0usize;
for path in &changed {
let abs = config.repo_root.join(path);
if abs.exists() {
if let Err(e) = index.notify_change(&abs) {
eprintln!("st update: {}: {e}", path.display());
notify_errors += 1;
} else {
count += 1;
}
} else {
if let Err(e) = index.notify_delete(&abs) {
eprintln!("st update: {}: {e}", path.display());
notify_errors += 1;
} else {
count += 1;
}
}
}
if let Err(e) = index.commit_batch() {
eprintln!("st update: commit failed: {e}");
return 2;
}
if !quiet {
let stdout = io::stdout();
let mut out = stdout.lock();
if let Err(err) = writeln!(out, "st: updated {} file(s)", count) {
return handle_output(err);
}
}
if notify_errors > 0 {
1
} else {
0
}
}
fn handle_output(err: io::Error) -> i32 {
if err.kind() == io::ErrorKind::BrokenPipe {
0
} else {
eprintln!("st: {err}");
2
}
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
use std::ffi::OsStr;
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
#[cfg(unix)]
use std::path::{Path, PathBuf};
#[cfg(unix)]
use super::is_safe_git_path;
use super::resolve_git_binary;
#[test]
fn git_binary_resolves_to_absolute_path() {
let path = resolve_git_binary();
assert!(
path.is_absolute(),
"git binary must resolve to absolute path, got: {:?}",
path
);
}
#[cfg(unix)]
#[test]
fn is_safe_git_path_rejects_traversal_and_absolute() {
assert!(!is_safe_git_path(Path::new("../../etc/passwd")));
assert!(!is_safe_git_path(Path::new("/etc/passwd")));
assert!(!is_safe_git_path(Path::new("src/../../../etc/passwd")));
assert!(!is_safe_git_path(Path::new("")));
assert!(is_safe_git_path(Path::new("src/main.rs")));
assert!(is_safe_git_path(Path::new("foo/bar/baz.rs")));
assert!(is_safe_git_path(Path::new("Cargo.toml")));
}
#[cfg(unix)]
#[test]
fn is_safe_git_path_accepts_non_utf8_relative_paths() {
let path = PathBuf::from(OsStr::from_bytes(b"src/\xff.rs"));
assert!(is_safe_git_path(&path));
}
}