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_private_temp_dir, extract_archive, extract_archive_with_hash, portable_path_key,
safe_windows_component, sha256,
},
expand,
project::{git_compatible_path, git_output, submodule_config, submodule_url_supported},
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()
}
fn load_manifest(path: &Path) -> anyhow::Result<(PathBuf, Manifest)> {
let path = fs::canonicalize(expand(path))?;
let manifest: Manifest = serde_json::from_slice(&fs::read(&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"
);
Ok((path, manifest))
}
fn selected_records<'a>(
records: &'a [Record],
projects: &[String],
) -> anyhow::Result<Vec<&'a Record>> {
if projects.is_empty() {
return Ok(records.iter().collect());
}
let wanted: HashSet<_> = projects.iter().map(String::as_str).collect();
let selected: Vec<_> = records
.iter()
.filter(|record| wanted.contains(record.project.as_str()))
.collect();
for project in projects {
anyhow::ensure!(
selected.iter().any(|record| record.project == *project),
"project not found in manifest: {project}"
);
}
Ok(selected)
}
pub(crate) fn verify_cmd(
manifest_path: &Path,
projects: &[String],
json: bool,
) -> anyhow::Result<()> {
let (manifest_path, manifest) = load_manifest(manifest_path)?;
let mut names = HashSet::new();
let mut results = Vec::new();
for record in selected_records(&manifest.records, projects)? {
anyhow::ensure!(
safe_name(&record.project),
"unsafe project name: {}",
record.project
);
anyhow::ensure!(
names.insert(record.project.clone()),
"duplicate project: {}",
record.project
);
match record.strategy.as_str() {
"archive" => {
let archive =
resolve_archive(record, manifest_path.parent().unwrap_or(Path::new(".")))?;
let expected = record
.sha256
.as_deref()
.filter(|hash| !hash.is_empty())
.ok_or_else(|| {
anyhow::anyhow!("manifest has no sha256 for {}", record.project)
})?;
let actual = sha256(&archive)?;
anyhow::ensure!(
actual.eq_ignore_ascii_case(expected),
"sha256 mismatch for {}",
archive.display()
);
results.push((
record.project.clone(),
"archive",
"verified",
Some(archive),
Some(actual),
None,
));
}
"git" => {
let url = record
.remote_url
.as_deref()
.ok_or_else(|| anyhow::anyhow!("missing remote_url"))?;
let commit = record
.commit
.as_deref()
.ok_or_else(|| anyhow::anyhow!("missing commit"))?;
validate_git_restore(url, commit)?;
results.push((
record.project.clone(),
"git",
"metadata-only",
None,
None,
Some(commit.to_owned()),
));
}
"git_bundle" => {
let bundle =
resolve_archive(record, manifest_path.parent().unwrap_or(Path::new(".")))?;
let expected = record
.sha256
.as_deref()
.filter(|hash| !hash.is_empty())
.ok_or_else(|| {
anyhow::anyhow!("manifest has no sha256 for {}", record.project)
})?;
let commit = record
.commit
.as_deref()
.ok_or_else(|| anyhow::anyhow!("missing commit for {}", record.project))?;
anyhow::ensure!(
(40..=64).contains(&commit.len())
&& commit.bytes().all(|byte| byte.is_ascii_hexdigit()),
"invalid Git bundle commit for {}",
record.project
);
let actual = sha256(&bundle)?;
anyhow::ensure!(
actual.eq_ignore_ascii_case(expected),
"sha256 mismatch for {}",
bundle.display()
);
results.push((
record.project.clone(),
"git_bundle",
"verified",
Some(bundle),
Some(actual),
Some(commit.to_owned()),
));
}
other => anyhow::bail!("unsupported backup strategy: {other}"),
}
}
if json {
let projects: Vec<_> = results
.iter()
.map(|(project, strategy, status, archive, detail, commit)| {
let mut value = serde_json::json!({
"project": project,
"strategy": strategy,
"status": status,
"archive": archive
.as_ref()
.map(|path| path.to_string_lossy().into_owned()),
});
value["sha256"] = if matches!(*strategy, "archive" | "git_bundle") {
serde_json::json!(detail)
} else {
serde_json::Value::Null
};
value["commit"] = serde_json::json!(commit);
value
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"manifest": manifest_path,
"projects": projects,
}))?
);
} else {
for (project, strategy, status, archive, detail, _commit) in &results {
if matches!(*strategy, "archive" | "git_bundle") {
let label = if *strategy == "git_bundle" {
"git-bundle"
} else {
"archive"
};
println!(
"[{label}] {project}: SHA-256 {status} {}",
archive.as_ref().unwrap().display()
);
} else {
let commit = detail.as_deref().unwrap_or_default();
println!(
"[git] {project}: manifest metadata valid (commit {}; remote not checked)",
&commit[..12.min(commit.len())]
);
}
}
println!("Verified {} project(s).", results.len());
}
Ok(())
}
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, manifest) = load_manifest(manifest_path)?;
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();
for r in selected_records(&manifest.records, projects)? {
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
}
"git_bundle" => {
let bundle = resolve_archive(r, manifest_path.parent().unwrap_or(Path::new(".")))?;
let branch = r
.branch
.as_deref()
.filter(|branch| !branch.is_empty())
.ok_or_else(|| anyhow::anyhow!("missing branch for {}", r.project))?;
anyhow::ensure!(
!branch.chars().any(char::is_whitespace),
"invalid Git bundle branch for {}",
r.project
);
let commit = r
.commit
.as_deref()
.ok_or_else(|| anyhow::anyhow!("missing commit for {}", r.project))?;
anyhow::ensure!(
(40..=64).contains(&commit.len())
&& commit.bytes().all(|byte| byte.is_ascii_hexdigit()),
"invalid Git bundle commit for {}",
r.project
);
if !no_verify {
let expected = r
.sha256
.as_deref()
.filter(|hash| !hash.is_empty())
.ok_or_else(|| {
anyhow::anyhow!("manifest has no sha256 for {}", r.project)
})?;
let actual = sha256(&bundle)?;
anyhow::ensure!(
actual.eq_ignore_ascii_case(expected),
"sha256 mismatch for {}",
bundle.display()
);
}
Some(bundle)
}
other => anyhow::bail!("unsupported backup strategy: {other}"),
};
if let Some(a) = &archive
&& !no_verify
&& dry
{
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));
}
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": matches!(r.strategy.as_str(), "archive" | "git_bundle") && !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 if r.strategy == "git_bundle" {
println!(
"[git-bundle] {}: {} -> {}",
r.project,
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"
)
}
if !no_verify {
for (record, _, archive) in &plans {
if let Some(archive) = archive {
let expected = record.sha256.as_deref().ok_or_else(|| {
anyhow::anyhow!("manifest has no sha256 for {}", record.project)
})?;
let actual = sha256(archive)?;
anyhow::ensure!(
actual.eq_ignore_ascii_case(expected),
"sha256 mismatch for {}",
archive.display()
);
}
}
}
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))?;
if no_verify {
extract_archive(archive, target, &r.project)?;
} else {
extract_archive_with_hash(
archive,
target,
&r.project,
r.sha256.as_deref(),
)?;
}
}
"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)?;
}
"git_bundle" => {
let bundle = archive
.as_ref()
.ok_or_else(|| anyhow::anyhow!("missing Git bundle for {}", r.project))?;
let branch = r
.branch
.as_deref()
.ok_or_else(|| anyhow::anyhow!("missing branch for {}", r.project))?;
let commit = r
.commit
.as_deref()
.ok_or_else(|| anyhow::anyhow!("missing commit for {}", r.project))?;
restore_git_bundle(
bundle,
branch,
commit,
r.sha256.as_deref(),
no_verify,
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_submodules(repo: &Path, project: &str) -> anyhow::Result<()> {
let mut pending = vec![(repo.to_path_buf(), 0usize)];
while let Some((parent, depth)) = pending.pop() {
anyhow::ensure!(depth <= 32, "submodule nesting is too deep for {project}");
restore_lfs(&parent, project)?;
let modules = submodule_config(&parent)
.map_err(|_| anyhow::anyhow!("invalid submodule metadata for {project}"))?;
if modules.is_empty() {
continue;
}
anyhow::ensure!(
modules
.iter()
.all(|module| submodule_url_supported(&module.url)),
"unsupported submodule URL for {project}"
);
let paths: Vec<_> = modules
.iter()
.map(|module| module.path.to_string_lossy().into_owned())
.collect();
let mut init = Command::new("git");
init.arg("-C")
.arg(&parent)
.args(["submodule", "--quiet", "init", "--"])
.args(&paths)
.stdout(Stdio::null());
anyhow::ensure!(
run_git_with_timeout(&mut init, project, "submodule init")?.success(),
"cannot initialize submodules for {project}"
);
let mut update = Command::new("git");
update
.args(["-c", "submodule.recurse=false"])
.env("GIT_LFS_SKIP_SMUDGE", "1")
.arg("-C")
.arg(&parent)
.args([
"submodule",
"--quiet",
"update",
"--init",
"--checkout",
"--",
])
.args(&paths)
.stdout(Stdio::null());
anyhow::ensure!(
run_git_with_timeout(&mut update, project, "submodule update")?.success(),
"cannot restore submodules for {project}"
);
let parent = fs::canonicalize(parent)?;
for module in modules {
let target = parent.join(module.path);
let metadata = fs::symlink_metadata(&target)?;
anyhow::ensure!(
!metadata.file_type().is_symlink() && metadata.is_dir(),
"invalid restored submodule for {project}"
);
let target = fs::canonicalize(target)?;
anyhow::ensure!(
target.starts_with(&parent),
"submodule escapes restore target for {project}"
);
let commit = git_output(&target, &["rev-parse", "HEAD"])
.filter(|commit| !commit.is_empty())
.ok_or_else(|| anyhow::anyhow!("cannot verify restored submodule for {project}"))?;
anyhow::ensure!(commit.len() >= 7, "invalid submodule commit for {project}");
pending.push((target, depth + 1));
}
}
Ok(())
}
fn restore_lfs(repo: &Path, project: &str) -> anyhow::Result<()> {
let lfsconfig = repo.join(".lfsconfig");
let has_lfsconfig = match fs::symlink_metadata(&lfsconfig) {
Ok(_) => true,
Err(error) => error.kind() != std::io::ErrorKind::NotFound,
};
anyhow::ensure!(
!has_lfsconfig,
"unsupported Git LFS configuration for {project}"
);
let attributes = Command::new("git")
.arg("-C")
.arg(repo)
.args(["grep", "-l", "filter=lfs", "--", "*.gitattributes"])
.output()?;
if attributes.status.code() == Some(1) {
return Ok(());
}
anyhow::ensure!(
attributes.status.success(),
"cannot inspect Git LFS attributes for {project}"
);
let endpoint = Command::new("git")
.arg("-C")
.arg(repo)
.args([
"config",
"--get-regexp",
r"^(lfs\..*\.url|remote\..*\.lfsurl)$",
])
.output()?;
if endpoint.status.success() && !endpoint.stdout.is_empty() {
anyhow::bail!("custom Git LFS endpoints are not supported for {project}");
}
anyhow::ensure!(
endpoint.status.success() || endpoint.status.code() == Some(1),
"cannot inspect Git LFS configuration for {project}"
);
anyhow::ensure!(
git_output(repo, &["lfs", "version"]).is_some(),
"Git LFS is required to restore {project}"
);
let mut install = Command::new("git");
install
.arg("-C")
.arg(repo)
.args(["lfs", "install", "--local"])
.stdout(Stdio::null());
anyhow::ensure!(
run_git_with_timeout(&mut install, project, "Git LFS install")?.success(),
"cannot configure Git LFS for {project}"
);
let mut pull = Command::new("git");
pull.arg("-C")
.arg(repo)
.args(["lfs", "pull"])
.stdout(Stdio::null());
anyhow::ensure!(
run_git_with_timeout(&mut pull, project, "Git LFS pull")?.success(),
"cannot restore Git LFS objects for {project}"
);
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_private_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])
.env("GIT_LFS_SKIP_SMUDGE", "1")
.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}"
);
restore_submodules(&repo, 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 restore_git_bundle(
bundle: &Path,
branch: &str,
commit: &str,
expected_hash: Option<&str>,
no_verify: bool,
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_private_temp_dir(parent)?;
let result = (|| -> anyhow::Result<()> {
let stable_bundle = temporary.join("source.bundle");
fs::copy(bundle, &stable_bundle)?;
let git_bundle_path = git_compatible_path(&stable_bundle);
if !no_verify {
let expected = expected_hash
.filter(|hash| !hash.is_empty())
.ok_or_else(|| anyhow::anyhow!("manifest has no sha256 for {project}"))?;
let actual = sha256(&stable_bundle)?;
anyhow::ensure!(
actual.eq_ignore_ascii_case(expected),
"sha256 mismatch for {}",
bundle.display()
);
}
let mut verify = Command::new("git");
verify.args(["bundle", "verify"]).arg(&git_bundle_path);
anyhow::ensure!(
run_git_with_timeout(&mut verify, project, "bundle verify")?.success(),
"invalid Git bundle for {project}"
);
let repo = temporary.join("repo");
let mut init = Command::new("git");
init.current_dir(&temporary).args(["init", "repo"]);
anyhow::ensure!(
run_git_with_timeout(&mut init, project, "init")?.success(),
"cannot initialize Git bundle restore for {project}"
);
let mut fetch = Command::new("git");
fetch
.env("GIT_TERMINAL_PROMPT", "0")
.arg("-C")
.arg(&repo)
.args(["fetch", "--no-tags"])
.arg(&git_bundle_path)
.args(["+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"]);
anyhow::ensure!(
run_git_with_timeout(&mut fetch, project, "bundle fetch")?.success(),
"cannot fetch Git bundle for {project}"
);
let mut check_branch = Command::new("git");
check_branch
.arg("-C")
.arg(&repo)
.args(["check-ref-format", "--branch", branch]);
anyhow::ensure!(
run_git_with_timeout(&mut check_branch, project, "branch validation")?.success(),
"invalid Git bundle branch for {project}"
);
let mut checkout = Command::new("git");
checkout
.arg("-C")
.arg(&repo)
.args(["checkout", "--force", "-B", branch, commit]);
anyhow::ensure!(
run_git_with_timeout(&mut checkout, project, "checkout")?.success(),
"cannot check out Git bundle commit for {project}"
);
let head = git_output(&repo, &["rev-parse", "HEAD"])
.filter(|head| !head.is_empty())
.ok_or_else(|| anyhow::anyhow!("cannot verify Git bundle commit for {project}"))?;
anyhow::ensure!(
head.eq_ignore_ascii_case(commit),
"Git bundle 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 {
safe_windows_component(s)
&& s != "."
&& s != ".."
&& !s.contains('/')
&& 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)
}