use std::{
collections::{BTreeSet, HashSet},
fmt, fs,
io::{self, IsTerminal, Read, 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);
const REMOTE_PROBE_TIMEOUT: Duration = Duration::from_secs(30);
use crate::{
Manifest, Record,
archive::{
create_private_temp_dir, extract_archive, extract_archive_with_hash,
extract_incremental_delta, portable_path_key, remove_deleted_paths, 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],
check_remote: bool,
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(VerifyResult::archive(
record.project.clone(),
"archive",
archive,
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)?;
if check_remote {
let probe = probe_remote(url, commit)?;
let submodules = probe_submodules(record)?;
let lfs = probe_lfs_objects(url, record.lfs_objects.as_deref().unwrap_or(&[]))?;
let status = if submodules
.iter()
.any(|module| module.remote.status == "remote-unreachable")
|| RemoteCheck::status(&probe) == "remote-unreachable"
{
"remote-unreachable".to_owned()
} else if lfs.iter().any(|object| {
object.status == "lfs-missing" || object.status == "lfs-unreachable"
}) {
"lfs-unavailable".to_owned()
} else {
RemoteCheck::status(&probe).to_owned()
};
results.push(VerifyResult {
project: record.project.clone(),
strategy: "git",
status,
archive: None,
sha256: None,
commit: Some(commit.to_owned()),
remote: Some(RemoteCheck::new(
url,
record.branch.as_deref().filter(|value| !value.is_empty()),
&probe,
)),
submodules,
lfs,
});
} else {
results.push(VerifyResult {
project: record.project.clone(),
strategy: "git",
status: "metadata-only".to_owned(),
archive: None,
sha256: None,
commit: Some(commit.to_owned()),
remote: None,
submodules: Vec::new(),
lfs: Vec::new(),
});
}
}
"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(VerifyResult::archive(
record.project.clone(),
"git_bundle",
bundle,
actual,
Some(commit.to_owned()),
));
}
other => anyhow::bail!("unsupported backup strategy: {other}"),
}
}
if json {
let entries: Vec<_> = results.iter().map(VerifyResult::json).collect();
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"manifest": manifest_path,
"projects": entries,
}))?
);
} else {
for result in &results {
println!("{}", result.line());
}
println!("Verified {} project(s).", results.len());
}
if results
.iter()
.any(|result| result.status == "remote-unreachable" || result.status == "lfs-unavailable")
{
anyhow::bail!("one or more remotes or LFS objects could not be verified");
}
Ok(())
}
struct VerifyResult {
project: String,
strategy: &'static str,
status: String,
archive: Option<PathBuf>,
sha256: Option<String>,
commit: Option<String>,
remote: Option<RemoteCheck>,
submodules: Vec<SubmoduleCheck>,
lfs: Vec<LfsCheck>,
}
struct LfsCheck {
oid: String,
size: Option<u64>,
status: &'static str,
reason: Option<&'static str>,
}
struct SubmoduleCheck {
path: String,
remote: RemoteCheck,
}
impl SubmoduleCheck {
fn new(path: &str, url: &str, probe: &RemoteProbe) -> Self {
Self {
path: path.to_owned(),
remote: RemoteCheck::new(url, None, probe),
}
}
}
impl VerifyResult {
fn archive(
project: String,
strategy: &'static str,
archive: PathBuf,
sha256: String,
commit: Option<String>,
) -> Self {
Self {
project,
strategy,
status: "verified".to_owned(),
archive: Some(archive),
sha256: Some(sha256),
commit,
remote: None,
submodules: Vec::new(),
lfs: Vec::new(),
}
}
fn json(&self) -> serde_json::Value {
let mut value = serde_json::json!({
"project": self.project,
"strategy": self.strategy,
"status": self.status,
"archive": self
.archive
.as_ref()
.map(|path| path.to_string_lossy().into_owned()),
"sha256": if matches!(self.strategy, "archive" | "git_bundle") {
self.sha256.clone()
} else {
None
},
"commit": self.commit,
});
if let Some(remote) = &self.remote {
value["remote"] = serde_json::json!({
"url": remote.url,
"branch": remote.branch,
"reason": remote.reason,
"refs": remote.refs,
});
}
if !self.submodules.is_empty() {
value["submodules"] = serde_json::Value::Array(
self.submodules
.iter()
.map(|sub| {
serde_json::json!({
"path": sub.path,
"url": sub.remote.url,
"branch": sub.remote.branch,
"reason": sub.remote.reason,
"refs": sub.remote.refs,
"status": sub.remote.status,
})
})
.collect(),
);
}
if !self.lfs.is_empty() {
value["lfs"] = serde_json::Value::Array(
self.lfs
.iter()
.map(|object| {
serde_json::json!({
"oid": object.oid,
"size": object.size,
"status": object.status,
"reason": object.reason,
})
})
.collect(),
);
}
value
}
fn line(&self) -> String {
if matches!(self.strategy, "archive" | "git_bundle") {
let label = if self.strategy == "git_bundle" {
"git-bundle"
} else {
"archive"
};
return format!(
"[{label}] {}: SHA-256 {} {}",
self.project,
self.status,
self.archive.as_ref().unwrap().display()
);
}
let commit = self.commit.as_deref().unwrap_or_default();
let short = &commit[..12.min(commit.len())];
let mut line = match &self.remote {
Some(remote) => remote.line(&self.project, short),
None => format!(
"[git] {}: manifest metadata valid (commit {short}; remote not checked)",
self.project
),
};
for sub in &self.submodules {
line.push('\n');
line.push_str(&sub.remote.submodule_line(&sub.path));
}
for object in &self.lfs {
line.push('\n');
let short_oid = &object.oid[..12.min(object.oid.len())];
let detail = match object.status {
"lfs-verified" => format!("available ({short_oid})"),
"lfs-missing" => format!(
"missing ({short_oid}; {})",
object.reason.unwrap_or("object-not-found")
),
"lfs-unreachable" => format!(
"unreachable ({short_oid}; {})",
object.reason.unwrap_or("lfs-probe-failed")
),
_ => format!(
"skipped ({short_oid}; {})",
object.reason.unwrap_or("not-probed")
),
};
line.push_str(&format!(" lfs {detail}"));
}
line
}
}
struct RemoteCheck {
url: String,
branch: Option<String>,
status: &'static str,
reason: Option<String>,
refs: Vec<String>,
}
impl RemoteCheck {
fn new(url: &str, branch: Option<&str>, probe: &RemoteProbe) -> Self {
let (status, reason, refs) = match probe {
RemoteProbe::Verified(refs) => ("remote-verified", None, refs.clone()),
RemoteProbe::Moved => ("remote-moved", Some("no-ref-matches-commit"), Vec::new()),
RemoteProbe::Skipped => (
"remote-skipped",
Some("credentials-in-remote-url"),
Vec::new(),
),
RemoteProbe::Unreachable(reason) => ("remote-unreachable", Some(*reason), Vec::new()),
};
Self {
url: redact_remote(url),
branch: branch.map(str::to_owned),
status,
reason: reason.map(str::to_owned),
refs,
}
}
fn status(probe: &RemoteProbe) -> &'static str {
match probe {
RemoteProbe::Verified(_) => "remote-verified",
RemoteProbe::Moved => "remote-moved",
RemoteProbe::Skipped => "remote-skipped",
RemoteProbe::Unreachable(_) => "remote-unreachable",
}
}
fn line(&self, project: &str, commit: &str) -> String {
match self.status {
"remote-verified" => {
let shown = self.refs[..self.refs.len().min(3)].join(", ");
let suffix = match self.refs.len().checked_sub(3) {
Some(extra) => format!(" (+{extra} more)"),
None => String::new(),
};
format!(
"[git] {project}: commit {commit} is at {shown}{suffix} on {}",
self.url
)
}
"remote-moved" => format!(
"[git] {project}: remote reachable, but no remote ref points at commit {commit} on {}; \
restore fetches by SHA, so the commit may still be reachable from another ref",
self.url
),
"remote-unreachable" => format!(
"[git] {project}: remote {} for {}",
self.reason.as_deref().unwrap_or("unreachable"),
self.url
),
_ => format!(
"[git] {project}: remote {}; not contacted",
self.reason.as_deref().unwrap_or("skipped")
),
}
}
fn submodule_line(&self, path: &str) -> String {
let status = self.status;
let detail = match status {
"remote-verified" => {
let shown = self.refs[..self.refs.len().min(3)].join(", ");
let suffix = match self.refs.len().checked_sub(3) {
Some(extra) => format!(" (+{extra} more)"),
None => String::new(),
};
format!("at {shown}{suffix} on {}", self.url)
}
"remote-moved" => format!(
"remote reachable, but no remote ref points at the pinned commit on {}",
self.url
),
"remote-unreachable" => format!(
"remote {} for {}",
self.reason.as_deref().unwrap_or("unreachable"),
self.url
),
_ => format!(
"remote {}; not contacted",
self.reason.as_deref().unwrap_or("skipped")
),
};
format!(" submodule {path}: {status} {detail}")
}
}
#[derive(Debug)]
pub(crate) enum RemoteProbe {
Verified(Vec<String>),
Moved,
Skipped,
Unreachable(&'static str),
}
pub(crate) fn probe_remote(url: &str, commit: &str) -> anyhow::Result<RemoteProbe> {
if redact_remote(url) != url {
return Ok(RemoteProbe::Skipped);
}
let mut command = Command::new("git");
command
.env("GIT_TERMINAL_PROMPT", "0")
.args(["ls-remote", url])
.stdout(Stdio::piped())
.stderr(Stdio::null());
#[cfg(unix)]
let interrupt = InterruptGuard::new()?;
#[cfg(unix)]
command.process_group(0);
let mut child = match command.spawn() {
Ok(child) => child,
Err(_) => return Ok(RemoteProbe::Unreachable("ls-remote-failed")),
};
let mut pipe = child.stdout.take();
let reader = thread::spawn(move || {
let mut output = String::new();
if let Some(handle) = &mut pipe {
let _ = handle.read_to_string(&mut output);
}
output
});
let deadline = Instant::now() + REMOTE_PROBE_TIMEOUT;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {}
Err(_) => {
let _ = stop_child(&mut child);
let _ = reader.join();
return Ok(RemoteProbe::Unreachable("ls-remote-failed"));
}
}
#[cfg(unix)]
if let Some(signal) = interrupt.requested() {
let _ = stop_child(&mut child);
let _ = reader.join();
return Err(RestoreInterrupted(signal).into());
}
if Instant::now() >= deadline {
let _ = stop_child(&mut child);
let _ = reader.join();
return Ok(RemoteProbe::Unreachable("ls-remote-timed-out"));
}
thread::sleep(Duration::from_millis(100));
};
let output = reader.join().unwrap_or_default();
if !status.success() {
return Ok(RemoteProbe::Unreachable("ls-remote-failed"));
}
let mut matched = BTreeSet::new();
for line in output.lines() {
let Some((sha, reference)) = line.split_once('\t') else {
continue;
};
if !sha.trim().eq_ignore_ascii_case(commit) {
continue;
}
let reference = reference.trim();
let reference = reference
.strip_suffix("^{}")
.unwrap_or(reference)
.to_owned();
if !reference.is_empty() {
matched.insert(reference);
}
}
if matched.is_empty() {
return Ok(RemoteProbe::Moved);
}
Ok(RemoteProbe::Verified(matched.into_iter().collect()))
}
fn probe_submodules(record: &crate::Record) -> anyhow::Result<Vec<SubmoduleCheck>> {
let mut checks = Vec::new();
if let Some(submodules) = &record.submodules {
for sub in submodules {
let probe = probe_remote(&sub.url, &sub.commit)?;
checks.push(SubmoduleCheck::new(&sub.path, &sub.url, &probe));
}
}
Ok(checks)
}
fn probe_lfs_objects(
remote_url: &str,
objects: &[crate::LfsObjectRef],
) -> anyhow::Result<Vec<LfsCheck>> {
if objects.is_empty() {
return Ok(Vec::new());
}
if redact_remote(remote_url) != remote_url {
return Ok(objects
.iter()
.map(|object| LfsCheck {
oid: object.oid().to_owned(),
size: object.size(),
status: "lfs-skipped",
reason: Some("credentials-in-remote-url"),
})
.collect());
}
let effective = rewrite_remote_url(remote_url);
if let Some(root) = local_lfs_store_root(&effective) {
return Ok(objects
.iter()
.map(|object| {
let oid = object.oid();
let path = root.join(&oid[..2]).join(&oid[2..4]).join(oid);
if path.is_file() {
LfsCheck {
oid: oid.to_owned(),
size: object.size(),
status: "lfs-verified",
reason: None,
}
} else {
LfsCheck {
oid: oid.to_owned(),
size: object.size(),
status: "lfs-missing",
reason: Some("object-not-found"),
}
}
})
.collect());
}
if effective.starts_with("https://") {
return probe_lfs_batch(&effective, objects);
}
Ok(objects
.iter()
.map(|object| LfsCheck {
oid: object.oid().to_owned(),
size: object.size(),
status: "lfs-skipped",
reason: Some("unsupported-lfs-endpoint"),
})
.collect())
}
fn rewrite_remote_url(url: &str) -> String {
let Ok(output) = Command::new("git")
.args(["config", "--get-regexp", r"^url\..+\.insteadof$"])
.env("GIT_TERMINAL_PROMPT", "0")
.output()
else {
return url.to_owned();
};
if !output.status.success() {
return url.to_owned();
}
let listing = String::from_utf8_lossy(&output.stdout);
let mut best: Option<(usize, String)> = None;
for line in listing.lines() {
let Some((key, prefix)) = line.split_once(char::is_whitespace) else {
continue;
};
let prefix = prefix.trim();
if prefix.is_empty() || !url.starts_with(prefix) {
continue;
}
let Some(base) = key
.strip_prefix("url.")
.and_then(|value| value.strip_suffix(".insteadof"))
else {
continue;
};
if best.as_ref().is_none_or(|(len, _)| prefix.len() > *len) {
best = Some((prefix.len(), format!("{base}{}", &url[prefix.len()..])));
}
}
best.map(|(_, rewritten)| rewritten)
.unwrap_or_else(|| url.to_owned())
}
fn local_lfs_store_root(url: &str) -> Option<PathBuf> {
let path = if url.starts_with("file://") {
let parsed = Url::parse(url).ok()?;
if parsed.scheme() != "file" {
return None;
}
parsed.to_file_path().ok()?
} else if url.starts_with('/') || (cfg!(windows) && Path::new(url).is_absolute()) {
PathBuf::from(url)
} else {
return None;
};
let store = path.join("lfs").join("objects");
if store.is_dir() || path.exists() {
Some(store)
} else {
None
}
}
fn probe_lfs_batch(
remote_url: &str,
objects: &[crate::LfsObjectRef],
) -> anyhow::Result<Vec<LfsCheck>> {
let mut checks = Vec::with_capacity(objects.len());
let mut batch = Vec::new();
for object in objects {
match object.size() {
Some(size) => batch.push((object.oid().to_owned(), size)),
None => checks.push(LfsCheck {
oid: object.oid().to_owned(),
size: None,
status: "lfs-skipped",
reason: Some("oid-only-no-size"),
}),
}
}
if batch.is_empty() {
return Ok(checks);
}
let endpoint = format!(
"{}/info/lfs/objects/batch",
remote_url.trim_end_matches('/')
);
let body = serde_json::json!({
"operation": "download",
"transfers": ["basic"],
"objects": batch.iter().map(|(oid, size)| serde_json::json!({
"oid": oid,
"size": size,
})).collect::<Vec<_>>(),
});
let agent = ureq::AgentBuilder::new()
.timeout_connect(REMOTE_PROBE_TIMEOUT)
.timeout_read(REMOTE_PROBE_TIMEOUT)
.build();
let response = match agent
.post(&endpoint)
.set("Accept", "application/vnd.git-lfs+json")
.set("Content-Type", "application/vnd.git-lfs+json")
.send_json(body)
{
Ok(response) => response,
Err(ureq::Error::Status(401 | 403, _)) => {
return Ok(batch
.into_iter()
.map(|(oid, size)| LfsCheck {
oid,
size: Some(size),
status: "lfs-unreachable",
reason: Some("lfs-auth-required"),
})
.chain(checks)
.collect());
}
Err(ureq::Error::Status(404, _)) => {
return Ok(batch
.into_iter()
.map(|(oid, size)| LfsCheck {
oid,
size: Some(size),
status: "lfs-missing",
reason: Some("lfs-batch-not-found"),
})
.chain(checks)
.collect());
}
Err(_) => {
return Ok(batch
.into_iter()
.map(|(oid, size)| LfsCheck {
oid,
size: Some(size),
status: "lfs-unreachable",
reason: Some("lfs-batch-failed"),
})
.chain(checks)
.collect());
}
};
let parsed: serde_json::Value = match response.into_json() {
Ok(value) => value,
Err(_) => {
return Ok(batch
.into_iter()
.map(|(oid, size)| LfsCheck {
oid,
size: Some(size),
status: "lfs-unreachable",
reason: Some("lfs-batch-invalid-json"),
})
.chain(checks)
.collect());
}
};
let reported = parsed
.get("objects")
.and_then(|value| value.as_array())
.cloned()
.unwrap_or_default();
let mut by_oid = std::collections::HashMap::new();
for entry in reported {
if let Some(oid) = entry.get("oid").and_then(|value| value.as_str()) {
by_oid.insert(oid.to_owned(), entry);
}
}
for (oid, size) in batch {
let check = match by_oid.get(&oid) {
Some(entry) if entry.get("error").is_some() => LfsCheck {
oid,
size: Some(size),
status: "lfs-missing",
reason: Some("object-not-found"),
},
Some(entry)
if entry
.pointer("/actions/download")
.is_some_and(|value| !value.is_null()) =>
{
LfsCheck {
oid,
size: Some(size),
status: "lfs-verified",
reason: None,
}
}
Some(_) => LfsCheck {
oid,
size: Some(size),
status: "lfs-missing",
reason: Some("no-download-action"),
},
None => LfsCheck {
oid,
size: Some(size),
status: "lfs-unreachable",
reason: Some("lfs-batch-incomplete"),
},
};
checks.push(check);
}
Ok(checks)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn restore_cmd(
manifest_path: &Path,
dest: Option<PathBuf>,
projects: &[String],
dry: bool,
no_verify: bool,
yes: bool,
check_remote: 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()
);
}
}
}
if check_remote {
for (record, _, _) in &plans {
if record.strategy != "git" {
continue;
}
let url = record
.remote_url
.as_deref()
.ok_or_else(|| anyhow::anyhow!("missing remote_url for {}", record.project))?;
let commit = record
.commit
.as_deref()
.ok_or_else(|| anyhow::anyhow!("missing commit for {}", record.project))?;
if let RemoteProbe::Unreachable(reason) = probe_remote(url, commit)? {
anyhow::bail!(
"cannot restore {}: remote {} is unreachable ({reason})",
record.project,
redact_remote(url)
);
}
if let Some(submodules) = &record.submodules {
for sub in submodules {
if let RemoteProbe::Unreachable(reason) = probe_remote(&sub.url, &sub.commit)? {
anyhow::bail!(
"cannot restore {}: submodule {} remote {} is unreachable ({reason})",
record.project,
sub.path,
redact_remote(&sub.url)
);
}
}
}
let lfs = probe_lfs_objects(url, record.lfs_objects.as_deref().unwrap_or(&[]))?;
if let Some(missing) = lfs
.iter()
.find(|object| object.status == "lfs-missing" || object.status == "lfs-unreachable")
{
anyhow::bail!(
"cannot restore {}: LFS object {} is {} ({})",
record.project,
&missing.oid[..12.min(missing.oid.len())],
missing.status,
missing.reason.unwrap_or("unavailable")
);
}
}
}
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 r.incremental.is_some() {
restore_incremental(
manifest_path.parent().unwrap_or(Path::new(".")),
r,
archive,
target,
no_verify,
)?;
} else 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());
ensure_git(
run_git_with_timeout(&mut init, project, "submodule init")?,
format!("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());
ensure_git(
run_git_with_timeout(&mut update, project, "submodule update")?,
format!("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());
ensure_git(
run_git_with_timeout(&mut install, project, "Git LFS install")?,
format!("cannot configure Git LFS for {project}"),
)?;
let mut pull = Command::new("git");
pull.arg("-C")
.arg(repo)
.args(["lfs", "pull"])
.stdout(Stdio::null());
ensure_git(
run_git_with_timeout(&mut pull, project, "Git LFS pull")?,
format!("cannot restore Git LFS objects for {project}"),
)?;
Ok(())
}
fn restore_incremental(
dir: &Path,
record: &Record,
archive: &Path,
target: &Path,
no_verify: bool,
) -> anyhow::Result<()> {
let mut chain: Vec<(Record, PathBuf)> = Vec::new();
let mut current = record.clone();
let mut current_archive = archive.to_path_buf();
loop {
let info = current.incremental.clone();
let is_base = info.is_none();
chain.push((current, current_archive));
if is_base {
break;
}
let info = info.expect("checked above");
let prev_path = dir.join(&info.base);
let (_, prev_manifest) = load_manifest(&prev_path)?;
let prev_record = prev_manifest
.records
.first()
.cloned()
.ok_or_else(|| anyhow::anyhow!("base manifest has no records"))?;
anyhow::ensure!(
prev_record.strategy == "archive",
"incremental chain crosses a non-archive snapshot: {}",
prev_record.project
);
let prev_archive = resolve_archive(&prev_record, dir)?;
current = prev_record;
current_archive = prev_archive;
}
chain.reverse();
let (base_record, base_archive) = &chain[0];
anyhow::ensure!(
base_record.incremental.is_none(),
"incremental chain does not terminate at a full snapshot"
);
if no_verify {
extract_archive(base_archive, target, &base_record.project)?;
} else {
extract_archive_with_hash(
base_archive,
target,
&base_record.project,
base_record.sha256.as_deref(),
)?;
}
for (rec, delta_archive) in &chain[1..] {
let info = rec.incremental.as_ref().ok_or_else(|| {
anyhow::anyhow!("incremental chain contains a non-incremental snapshot")
})?;
remove_deleted_paths(target, &info.deleted)?;
let expected = if no_verify {
None
} else {
rec.sha256.as_deref()
};
extract_incremental_delta(delta_archive, target, &rec.project, expected)?;
}
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());
ensure_git(
run_git_with_timeout(&mut init, project, "init")?,
format!("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());
ensure_git(
run_git_with_timeout(&mut add_fetch, project, "config")?,
format!("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.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());
ensure_git(
run_git_with_timeout(&mut fetch, project, "fetch")?,
format!("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());
ensure_git(
run_git_with_timeout(&mut remove_fetch_remote, project, "config")?,
format!("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());
ensure_git(
run_git_with_timeout(&mut add_remote, project, "remote add")?,
format!("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());
ensure_git(
run_git_with_timeout(&mut checkout, project, "checkout")?,
format!("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)?);
ensure_git(
run_git_with_timeout(&mut head, project, "rev-parse")?,
format!("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 repo = temporary.join("repo");
let mut init = Command::new("git");
init.current_dir(&temporary).args(["init", "repo"]);
ensure_git(
run_git_with_timeout(&mut init, project, "init")?,
format!("cannot initialize Git bundle restore for {project}"),
)?;
let mut detach_head = Command::new("git");
detach_head.arg("-C").arg(&repo).args([
"symbolic-ref",
"HEAD",
"refs/heads/projjan-restore-temp",
]);
ensure_git(
run_git_with_timeout(&mut detach_head, project, "bundle HEAD prepare")?,
format!("cannot prepare Git bundle restore for {project}"),
)?;
let mut verify = Command::new("git");
verify
.arg("-C")
.arg(&repo)
.args(["bundle", "verify"])
.arg(&git_bundle_path);
ensure_git(
run_git_with_timeout(&mut verify, project, "bundle verify")?,
format!("invalid Git bundle 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/*"]);
ensure_git(
run_git_with_timeout(&mut fetch, project, "bundle fetch")?,
format!("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]);
ensure_git(
run_git_with_timeout(&mut check_branch, project, "branch validation")?,
format!("invalid Git bundle branch for {project}"),
)?;
let mut checkout = Command::new("git");
checkout
.arg("-C")
.arg(&repo)
.args(["checkout", "--force", "-B", branch, commit]);
ensure_git(
run_git_with_timeout(&mut checkout, project, "checkout")?,
format!("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
}
struct GitRun {
status: ExitStatus,
stderr: String,
}
fn ensure_git(run: GitRun, message: String) -> anyhow::Result<()> {
if run.status.success() {
return Ok(());
}
let detail = summarize_git_stderr(&run.stderr);
if detail.is_empty() {
anyhow::bail!("{message}");
}
anyhow::bail!("{message}: {detail}");
}
fn summarize_git_stderr(stderr: &str) -> String {
let trimmed = stderr.trim();
if trimmed.is_empty() {
return String::new();
}
let line = trimmed
.lines()
.rev()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or(trimmed);
redact_git_message(line)
}
fn redact_git_message(message: &str) -> String {
if let Some(scheme) = message.find("://") {
let after = &message[scheme + 3..];
if let Some(at) = after.find('@') {
let userinfo = &after[..at];
if userinfo.contains(':') {
return "remote URL details redacted".to_owned();
}
}
}
message.to_owned()
}
fn run_git_with_timeout(
command: &mut Command,
project: &str,
operation: &str,
) -> anyhow::Result<GitRun> {
#[cfg(unix)]
let interrupt = InterruptGuard::new()?;
#[cfg(unix)]
command.process_group(0);
command.env("GIT_PAGER", "cat");
let mut child = command.stderr(Stdio::piped()).spawn()?;
let mut stderr_pipe = child.stderr.take();
let reader = thread::spawn(move || {
let mut output = String::new();
if let Some(handle) = &mut stderr_pipe {
let _ = handle.read_to_string(&mut output);
}
output
});
let deadline = Instant::now() + GIT_TIMEOUT;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {}
Err(error) => {
let _ = stop_child(&mut child);
let _ = reader.join();
return Err(error.into());
}
}
#[cfg(unix)]
if let Some(signal) = interrupt.requested() {
stop_child(&mut child)?;
let _ = reader.join();
return Err(RestoreInterrupted(signal).into());
}
if Instant::now() >= deadline {
stop_child(&mut child)?;
let _ = reader.join();
anyhow::bail!(
"git {operation} timed out after {} seconds for {project}",
GIT_TIMEOUT.as_secs()
);
}
thread::sleep(Duration::from_millis(100));
};
let stderr = reader.join().unwrap_or_default();
Ok(GitRun { status, stderr })
}
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)
}