use std::{
collections::HashSet,
fmt, fs,
io::{self, IsTerminal, Write},
path::{Component, Path, PathBuf},
process::{Child, Command, ExitStatus, Stdio},
thread,
time::{Duration, Instant},
};
use url::Url;
#[cfg(unix)]
use std::{
os::unix::process::CommandExt,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
};
const GIT_TIMEOUT: Duration = Duration::from_secs(600);
use crate::{
Manifest, Record,
archive::{create_restore_temp_dir, extract_archive, portable_path_key, sha256},
expand, resolve_for_creation,
};
pub(crate) fn validate_git_restore(remote: &str, commit: &str) -> anyhow::Result<()> {
anyhow::ensure!(
(7..=64).contains(&commit.len()) && commit.bytes().all(|b| b.is_ascii_hexdigit()),
"invalid commit in manifest"
);
anyhow::ensure!(
!remote.is_empty() && !remote.starts_with('-') && !remote.chars().any(char::is_whitespace),
"invalid remote URL in manifest"
);
if remote.starts_with("https://")
|| remote.starts_with("ssh://")
|| remote.starts_with("file://")
{
let parsed =
Url::parse(remote).map_err(|_| anyhow::anyhow!("invalid remote URL in manifest"))?;
anyhow::ensure!(
matches!(parsed.scheme(), "file") || parsed.host_str().is_some(),
"remote URL has no host"
);
anyhow::ensure!(
parsed.scheme() != "file"
|| (parsed.username().is_empty()
&& parsed.password().is_none()
&& parsed.query().is_none()
&& parsed.fragment().is_none()),
"file remote URL must not contain credentials or query data"
);
return Ok(());
}
if Path::new(remote).is_absolute() {
return Ok(());
}
if let Some((identity, path)) = remote.split_once(':') {
let Some((user, host)) = identity.rsplit_once('@') else {
anyhow::bail!("unsupported remote URL in manifest")
};
anyhow::ensure!(
!user.is_empty()
&& !host.is_empty()
&& !path.is_empty()
&& host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b".-".contains(&b)),
"unsupported remote URL in manifest"
);
return Ok(());
}
anyhow::bail!("unsupported remote URL in manifest")
}
pub(crate) fn redact_remote(remote: &str) -> String {
if remote.starts_with("http://")
|| remote.starts_with("https://")
|| remote.starts_with("ssh://")
{
let Ok(mut parsed) = Url::parse(remote) else {
return "<invalid>".into();
};
if parsed.username().is_empty()
&& parsed.password().is_none()
&& parsed.query().is_none()
&& parsed.fragment().is_none()
{
return remote.to_owned();
}
let username = if parsed.scheme() == "ssh" {
parsed.username().to_owned()
} else {
String::new()
};
if parsed.set_username(&username).is_err() || parsed.set_password(None).is_err() {
return "<invalid>".into();
}
parsed.set_query(None);
parsed.set_fragment(None);
return parsed.to_string();
}
remote.to_owned()
}
pub(crate) fn restore_cmd(
manifest_path: &Path,
dest: Option<PathBuf>,
projects: &[String],
dry: bool,
no_verify: bool,
yes: bool,
json: bool,
) -> anyhow::Result<()> {
let manifest_path = fs::canonicalize(expand(manifest_path))?;
let manifest: Manifest = serde_json::from_slice(&fs::read(&manifest_path)?)?;
anyhow::ensure!(
manifest.schema_version == 2,
"unsupported manifest schema_version: {}",
manifest.schema_version
);
anyhow::ensure!(
!manifest.records.is_empty(),
"manifest records must be a non-empty array"
);
let dest = if let Some(dest) = dest {
let resolved = resolve_for_creation(&expand(&dest))?;
anyhow::ensure!(
!resolved.exists() || resolved.is_dir(),
"destination is not a directory: {}",
resolved.display()
);
Some(resolved)
} else {
None
};
let mut plans = Vec::new();
let mut names = HashSet::new();
let mut portable_targets = HashSet::new();
let wanted: HashSet<_> = projects.iter().collect();
for r in &manifest.records {
if !wanted.is_empty() && !wanted.contains(&r.project) {
continue;
}
anyhow::ensure!(safe_name(&r.project), "unsafe project name: {}", r.project);
anyhow::ensure!(
names.insert(r.project.clone()),
"duplicate project: {}",
r.project
);
let target = if let Some(d) = &dest {
d.join(&r.project)
} else {
let src = PathBuf::from(&r.source);
anyhow::ensure!(
src.is_absolute()
&& !src.components().any(|c| matches!(c, Component::ParentDir))
&& src
.file_name()
.is_some_and(|n| n.to_string_lossy() == r.project),
"unsafe source path for {}",
r.project
);
anyhow::ensure!(src.parent().is_some(), "restore source has no parent");
resolve_for_creation(&src)?
};
anyhow::ensure!(
portable_targets.insert(portable_path_key(&target.to_string_lossy())),
"restore targets collide on case-insensitive filesystems (including Unicode normalization): {}",
target.display()
);
anyhow::ensure!(
!target.exists() && !target.is_symlink(),
"restore target already exists: {}",
target.display()
);
let archive = match r.strategy.as_str() {
"archive" => Some(resolve_archive(
r,
manifest_path.parent().unwrap_or(Path::new(".")),
)?),
"git" => {
let url = r
.remote_url
.as_deref()
.ok_or_else(|| anyhow::anyhow!("missing remote_url"))?;
let commit = r
.commit
.as_deref()
.ok_or_else(|| anyhow::anyhow!("missing commit"))?;
validate_git_restore(url, commit)?;
None
}
other => anyhow::bail!("unsupported backup strategy: {other}"),
};
if let Some(a) = &archive
&& !no_verify
{
let expect = r
.sha256
.as_ref()
.ok_or_else(|| anyhow::anyhow!("manifest has no sha256 for {}", r.project))?;
let actual = sha256(a)?;
anyhow::ensure!(
actual.eq_ignore_ascii_case(expect),
"sha256 mismatch for {}",
a.display()
);
}
plans.push((r.clone(), target, archive));
}
for p in projects {
anyhow::ensure!(
plans.iter().any(|(r, _, _)| &r.project == p),
"project not found in manifest: {p}"
)
}
if dry {
if json {
let vals: Vec<_> = plans
.iter()
.map(|(r, t, a)| {
serde_json::json!({
"project": r.project,
"strategy": r.strategy,
"target": t,
"archive": a,
"remote_url": r.remote_url.as_deref().map(redact_remote),
"commit": r.commit,
"sha256": r.sha256,
"sha256_verified": r.strategy == "archive" && !no_verify,
"in_place": dest.is_none(),
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(
&serde_json::json!({"dry_run":true,"mode":if dest.is_some(){"dest"}else{"original"},"projects":vals})
)?
)
} else {
for (r, t, a) in &plans {
if r.strategy == "git" {
println!(
"[git] {}: {} @ {} -> {}",
r.project,
r.remote_url
.as_deref()
.map(redact_remote)
.unwrap_or_default(),
r.commit.as_deref().unwrap_or("-"),
t.display()
);
} else {
println!(
"[archive] {}: {} -> {}",
r.project,
a.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default(),
t.display()
);
}
}
println!("Dry run.")
}
return Ok(());
}
if dest.is_none() && !yes {
anyhow::ensure!(
io::stdin().is_terminal() && !json,
"--yes is required for original-location restore in non-interactive or JSON mode"
);
eprintln!("About to restore to original locations:");
for (record, target, _) in &plans {
eprintln!(" {}: {}", record.project, target.display());
}
eprint!(
"Restore {} project(s) to original locations? [y/N] ",
plans.len()
);
io::stderr().flush()?;
let mut a = String::new();
io::stdin().read_line(&mut a)?;
anyhow::ensure!(
matches!(a.trim().to_lowercase().as_str(), "y" | "yes"),
"Aborted"
)
}
let mut done = Vec::new();
for (r, target, archive) in &plans {
let result = (|| -> anyhow::Result<()> {
fs::create_dir_all(
target
.parent()
.ok_or_else(|| anyhow::anyhow!("target has no parent"))?,
)?;
match r.strategy.as_str() {
"archive" => {
let archive = archive
.as_ref()
.ok_or_else(|| anyhow::anyhow!("missing archive for {}", r.project))?;
extract_archive(archive, target, &r.project)?;
}
"git" => {
let url = r
.remote_url
.as_ref()
.ok_or_else(|| anyhow::anyhow!("missing remote_url"))?;
let commit = r
.commit
.as_ref()
.ok_or_else(|| anyhow::anyhow!("missing commit"))?;
restore_git(url, commit, target, &r.project)?;
}
x => anyhow::bail!("unsupported backup strategy: {x}"),
};
Ok(())
})();
if let Err(error) = result {
if done.is_empty() {
return Err(error);
}
let completed = done
.iter()
.map(|(project, _, path): &(String, String, PathBuf)| {
format!("{project} -> {}", path.display())
})
.collect::<Vec<_>>()
.join(", ");
return Err(anyhow::anyhow!("{error}; already restored: {completed}"));
}
done.push((r.project.clone(), r.strategy.clone(), target.clone()));
}
if json {
println!(
"{}",
serde_json::to_string_pretty(
&serde_json::json!({"dry_run":false,"mode":if dest.is_some(){"dest"}else{"original"},"projects":done.iter().map(|(p,s,t)|serde_json::json!({"project":p,"strategy":s,"target":t})).collect::<Vec<_>>()})
)?
)
} else {
println!("Restored {} project(s).", done.len());
for (p, s, t) in done {
println!("[{s}] {p} -> {}", t.display())
}
}
Ok(())
}
fn restore_git(url: &str, commit: &str, target: &Path, project: &str) -> anyhow::Result<()> {
anyhow::ensure!(
!target.exists() && !target.is_symlink(),
"restore target already exists: {}",
target.display()
);
let parent = target
.parent()
.ok_or_else(|| anyhow::anyhow!("target has no parent"))?;
fs::create_dir_all(parent)?;
let temporary = create_restore_temp_dir(parent)?;
let repo = temporary.join("repo");
let result = (|| -> anyhow::Result<()> {
let safe_url = redact_remote(url);
let mut init = Command::new("git");
init.arg("-C")
.arg(&temporary)
.args(["init", "repo"])
.stdout(Stdio::null());
anyhow::ensure!(
run_git_with_timeout(&mut init, project, "init")?.success(),
"git init failed for {project}"
);
let remote_suffix = temporary
.file_name()
.unwrap_or_default()
.to_string_lossy()
.replace(['.', '-'], "_");
let fetch_remote = format!("projjan_{remote_suffix}");
let refspecs = [
"+refs/heads/*:refs/remotes/origin/*",
"+refs/tags/*:refs/tags/*",
];
for refspec in refspecs {
let mut add_fetch = Command::new("git");
add_fetch
.arg("-C")
.arg(&repo)
.args(["config", "--local", "--add"])
.arg(format!("remote.{fetch_remote}.fetch"))
.arg(refspec)
.stdout(Stdio::null());
anyhow::ensure!(
run_git_with_timeout(&mut add_fetch, project, "config")?.success(),
"cannot configure Git fetch for {project}"
);
}
const CONFIG_PROBE_URL: &str = "https://projjan.invalid/";
let probe_env = git_config_env(&format!("remote.{fetch_remote}.url"), CONFIG_PROBE_URL);
let uses_config_env = if let Some(probe_env) = &probe_env {
let probe_output = temporary.join("config-probe");
let mut probe = Command::new("git");
probe
.arg("-C")
.arg(&repo)
.args(["config", "--get"])
.arg(format!("remote.{fetch_remote}.url"))
.stdout(fs::File::create(&probe_output)?);
for (key, value) in probe_env {
probe.env(key, value);
}
let probe_status = run_git_with_timeout(&mut probe, project, "config probe")?;
drop(probe);
let supported = probe_status.success()
&& fs::read_to_string(&probe_output)?.trim() == CONFIG_PROBE_URL;
let _ = fs::remove_file(probe_output);
supported
} else {
false
};
anyhow::ensure!(
uses_config_env || safe_url == url,
"Git cannot safely pass URL credentials or query data; upgrade Git or use a credential helper or SSH"
);
let mut fetch = Command::new("git");
fetch
.env("GIT_TERMINAL_PROMPT", "0")
.arg("-C")
.arg(&repo)
.args(["fetch", "--no-write-fetch-head", "--no-tags"]);
if uses_config_env {
fetch.arg(&fetch_remote);
let actual_env = git_config_env(&format!("remote.{fetch_remote}.url"), url)
.ok_or_else(|| anyhow::anyhow!("cannot prepare Git config environment"))?;
for (key, value) in actual_env {
fetch.env(key, value);
}
} else {
fetch.arg("--").arg(url).args(refspecs);
}
fetch.stdout(Stdio::null());
anyhow::ensure!(
run_git_with_timeout(&mut fetch, project, "fetch")?.success(),
"git fetch failed for {project}"
);
let mut remove_fetch_remote = Command::new("git");
remove_fetch_remote
.arg("-C")
.arg(&repo)
.args(["config", "--local", "--remove-section"])
.arg(format!("remote.{fetch_remote}"))
.stdout(Stdio::null());
anyhow::ensure!(
run_git_with_timeout(&mut remove_fetch_remote, project, "config")?.success(),
"cannot remove temporary Git remote for {project}"
);
let mut add_remote = Command::new("git");
add_remote
.arg("-C")
.arg(&repo)
.args(["remote", "add", "origin", &safe_url])
.stdout(Stdio::null());
anyhow::ensure!(
run_git_with_timeout(&mut add_remote, project, "remote add")?.success(),
"cannot configure restored remote for {project}"
);
let mut checkout = Command::new("git");
checkout
.arg("-C")
.arg(&repo)
.args(["checkout", "--detach", commit])
.stdout(Stdio::null());
anyhow::ensure!(
run_git_with_timeout(&mut checkout, project, "checkout")?.success(),
"git checkout failed for {project}"
);
let head_output = temporary.join("head");
let mut head = Command::new("git");
head.arg("-C")
.arg(&repo)
.args(["rev-parse", "HEAD"])
.stdout(fs::File::create(&head_output)?);
anyhow::ensure!(
run_git_with_timeout(&mut head, project, "rev-parse")?.success(),
"cannot verify restored commit for {project}"
);
drop(head);
let actual = fs::read_to_string(head_output)?.trim().to_owned();
anyhow::ensure!(
actual
.to_ascii_lowercase()
.starts_with(&commit.to_ascii_lowercase()),
"restored commit does not match manifest for {project}"
);
anyhow::ensure!(
!target.exists() && !target.is_symlink(),
"restore target already exists: {}",
target.display()
);
fs::rename(&repo, target)?;
Ok(())
})();
let _ = fs::remove_dir_all(&temporary);
result
}
fn run_git_with_timeout(
command: &mut Command,
project: &str,
operation: &str,
) -> anyhow::Result<ExitStatus> {
#[cfg(unix)]
let interrupt = InterruptGuard::new()?;
#[cfg(unix)]
command.process_group(0);
let mut child = command.stderr(Stdio::null()).spawn()?;
let deadline = Instant::now() + GIT_TIMEOUT;
loop {
match child.try_wait() {
Ok(Some(status)) => return Ok(status),
Ok(None) => {}
Err(error) => {
let _ = stop_child(&mut child);
return Err(error.into());
}
}
#[cfg(unix)]
if let Some(signal) = interrupt.requested() {
stop_child(&mut child)?;
return Err(RestoreInterrupted(signal).into());
}
if Instant::now() >= deadline {
stop_child(&mut child)?;
anyhow::bail!(
"git {operation} timed out after {} seconds for {project}",
GIT_TIMEOUT.as_secs()
);
}
thread::sleep(Duration::from_millis(100));
}
}
fn git_config_env(key: &str, value: &str) -> Option<Vec<(String, String)>> {
let count = match std::env::var("GIT_CONFIG_COUNT") {
Ok(value) if value.is_empty() => 0,
Ok(value) => value.parse::<usize>().ok()?,
Err(std::env::VarError::NotPresent) => 0,
Err(std::env::VarError::NotUnicode(_)) => return None,
};
let next = count.checked_add(1)?;
Some(vec![
("GIT_CONFIG_COUNT".into(), next.to_string()),
(format!("GIT_CONFIG_KEY_{count}"), key.to_owned()),
(format!("GIT_CONFIG_VALUE_{count}"), value.to_owned()),
])
}
#[cfg(unix)]
struct InterruptGuard {
interrupted: Arc<AtomicUsize>,
signals: Vec<signal_hook::SigId>,
}
#[cfg(unix)]
impl InterruptGuard {
fn new() -> anyhow::Result<Self> {
let interrupted = Arc::new(AtomicUsize::new(0));
let mut guard = Self {
interrupted: Arc::clone(&interrupted),
signals: Vec::new(),
};
guard.signals.push(signal_hook::flag::register_usize(
signal_hook::consts::SIGINT,
Arc::clone(&interrupted),
signal_hook::consts::SIGINT as usize,
)?);
guard.signals.push(signal_hook::flag::register_usize(
signal_hook::consts::SIGTERM,
interrupted,
signal_hook::consts::SIGTERM as usize,
)?);
Ok(guard)
}
fn requested(&self) -> Option<i32> {
match self.interrupted.load(Ordering::Relaxed) {
0 => None,
signal => Some(signal as i32),
}
}
}
#[cfg(unix)]
impl Drop for InterruptGuard {
fn drop(&mut self) {
for signal in &self.signals {
signal_hook::low_level::unregister(*signal);
}
}
}
#[derive(Debug)]
pub(crate) struct RestoreInterrupted(pub(crate) i32);
impl fmt::Display for RestoreInterrupted {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "interrupted by signal {}", self.0)
}
}
impl std::error::Error for RestoreInterrupted {}
fn stop_child(child: &mut Child) -> anyhow::Result<()> {
#[cfg(unix)]
{
let pid = libc::pid_t::try_from(child.id())?;
if unsafe { libc::kill(-pid, libc::SIGKILL) } != 0 {
let _ = child.kill();
}
}
#[cfg(windows)]
{
let pid = child.id().to_string();
let killed = Command::new("taskkill")
.args(["/F", "/T", "/PID", &pid])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success());
if !killed {
let _ = child.kill();
}
}
#[cfg(not(any(unix, windows)))]
let _ = child.kill();
child.wait()?;
Ok(())
}
fn safe_name(s: &str) -> bool {
!s.is_empty()
&& s != "."
&& s != ".."
&& !s.contains('/')
&& !s.contains('\\')
&& !s.contains('\0')
&& Path::new(s).file_name().is_some_and(|n| n == s)
}
fn resolve_archive(r: &Record, dir: &Path) -> anyhow::Result<PathBuf> {
let raw = PathBuf::from(
r.archive
.as_ref()
.ok_or_else(|| anyhow::anyhow!("missing archive for {}", r.project))?,
);
let mut v = Vec::new();
if raw.is_absolute() {
v.push(raw.clone())
} else {
v.push(dir.join(&raw))
}
let name = raw
.file_name()
.ok_or_else(|| anyhow::anyhow!("invalid archive path"))?;
v.push(dir.join(name));
v.push(dir.join(&r.project).join(name));
for p in v {
if p.is_file() {
return Ok(fs::canonicalize(p)?);
}
}
anyhow::bail!("archive not found for {}", r.project)
}