use std::fs::{self, File, OpenOptions};
use std::io::{Read, Seek, Write};
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use tempfile::NamedTempFile;
use super::{Checkout, Directory};
use crate::git::Git;
use crate::{interrupt, progress, skills};
const TOKEN: &str = ".thirdshift-review-token";
const RECORD: &str = "thirdshift-review.json";
const VERSION: u32 = 1;
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Record {
version: u32,
nonce: String,
detached: bool,
root: DirectoryRecord,
admin: DirectoryRecord,
common: DirectoryRecord,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct DirectoryRecord {
path: PathBuf,
device: u64,
inode: u64,
}
impl DirectoryRecord {
fn capture(directory: &Directory) -> Result<Self> {
directory.verify()?;
let path = directory.path.canonicalize()?;
if path != directory.path {
bail!(
"directory path is not canonical: {}",
directory.path.display()
);
}
let metadata = directory.handle.metadata()?;
Ok(Self {
path,
device: metadata.dev(),
inode: metadata.ino(),
})
}
fn verify(&self, directory: &Directory) -> Result<()> {
let current = Self::capture(directory)?;
if self.path != current.path || self.device != current.device || self.inode != current.inode
{
bail!("recorded directory {} was replaced", self.path.display());
}
Ok(())
}
}
#[derive(Default)]
pub(in crate::worktree) struct Publication {
token: Option<Artifact>,
record: Option<NamedTempFile>,
}
impl Publication {
pub(in crate::worktree) fn publish(&mut self, launch: &Git, checkout: &Checkout) -> Result<()> {
let git = Git::new(checkout.path());
if git.succeeds(&["ls-files", "--error-unmatch", "--", TOKEN])? {
bail!("review token path is tracked; refusing to replace it");
}
skills::exclude(&git, &format!("/{TOKEN}"))?;
if !git.succeeds(&["check-ignore", "--quiet", "--", TOKEN])? {
bail!("review token is not excluded by Git; check repository ignore rules");
}
let mut temporary = tempfile::Builder::new()
.prefix(".thirdshift-review-")
.rand_bytes(32)
.tempfile_in(&checkout.admin.path)?;
temporary.disable_cleanup(true);
self.record = Some(temporary);
let temporary = self
.record
.as_mut()
.context("missing prepared review record")?;
let nonce = temporary
.path()
.file_name()
.and_then(|name| name.to_str())
.context("review nonce is not UTF-8")?
.to_string();
let token_path = checkout.path().join(TOKEN);
let token_file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(&token_path)
.context("cannot exclusively create review token")?;
self.token = Some(Artifact {
path: token_path,
file: token_file,
});
let token = self.token.as_mut().context("missing review token")?;
token.file.write_all(nonce.as_bytes())?;
token.file.sync_all()?;
let record = Record {
version: VERSION,
nonce,
detached: true,
root: DirectoryRecord::capture(&checkout.root)?,
admin: DirectoryRecord::capture(&checkout.admin)?,
common: DirectoryRecord::capture(&checkout.common)?,
};
temporary.write_all(&serde_json::to_vec(&record)?)?;
temporary.as_file().sync_all()?;
checkout.inspect(launch)?;
token.verify()?;
verify_file(temporary.path(), temporary.as_file())?;
interrupt::check()?;
let temporary = self
.record
.take()
.context("missing prepared review record")?;
match temporary.persist_noclobber(checkout.admin.path.join(RECORD)) {
Ok(_) => Ok(()),
Err(error) => {
self.record = Some(error.file);
Err(error.error).context("cannot publish successful review acquisition")
}
}
}
pub(in crate::worktree) fn remove_artifacts(&self) -> Result<()> {
if let Some(temporary) = &self.record {
verify_file(temporary.path(), temporary.as_file())
.context("prepared review record has uncertain ownership")?;
fs::remove_file(temporary.path())
.context("cannot remove attempt-owned review record")?;
}
if let Some(token) = &self.token {
token
.verify()
.context("review token has uncertain ownership")?;
fs::remove_file(&token.path).context("cannot remove attempt-owned review token")?;
}
Ok(())
}
}
struct Artifact {
path: PathBuf,
file: File,
}
impl Artifact {
fn read(path: PathBuf) -> Result<Self> {
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(&path)
.with_context(|| format!("cannot read disposal evidence {}", path.display()))?;
let artifact = Self { path, file };
artifact.verify()?;
Ok(artifact)
}
fn verify(&self) -> Result<()> {
verify_file(&self.path, &self.file)
}
fn contents(&self) -> Result<Vec<u8>> {
self.verify()?;
let mut file = &self.file;
file.rewind()?;
let mut contents = Vec::new();
file.read_to_end(&mut contents)?;
self.verify()?;
Ok(contents)
}
}
fn verify_file(path: &Path, file: &File) -> Result<()> {
let current = fs::symlink_metadata(path)?;
let pinned = file.metadata()?;
if !current.is_file()
|| !pinned.is_file()
|| current.dev() != pinned.dev()
|| current.ino() != pinned.ino()
{
bail!(
"evidence {} was replaced or is not a regular file",
path.display()
);
}
Ok(())
}
pub(in crate::worktree) fn recover(launch: &Git, path: &Path) -> Result<()> {
let mut registered_head = None;
let mut inspect_and_remove = || -> Result<()> {
let entries = super::registrations(launch)?;
registered_head = entries
.iter()
.find(|entry| entry.path == path)
.and_then(|entry| entry.head.clone());
let exists = match fs::symlink_metadata(path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
bail!("checkout path is a symlink");
}
true
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
Err(error) => return Err(error.into()),
};
if !exists && !entries.iter().any(|entry| entry.path == path) {
return Ok(());
}
let checkout = Checkout::capture(launch, path, None)?;
let record_file = Artifact::read(checkout.admin.path.join(RECORD))?;
let record_contents = record_file.contents()?;
let record: Record = serde_json::from_slice(&record_contents)
.context("invalid successful review acquisition record")?;
if record.version != VERSION || !record.detached || record.nonce.is_empty() {
bail!("unsupported or inconsistent successful review acquisition record");
}
record.root.verify(&checkout.root)?;
record.admin.verify(&checkout.admin)?;
record.common.verify(&checkout.common)?;
let token = Artifact::read(checkout.path().join(TOKEN))?;
let token_contents = token.contents()?;
let nonce = std::str::from_utf8(&token_contents)?;
if nonce != record.nonce {
bail!("checkout token does not match successful review acquisition record");
}
token.verify()?;
record_file.verify()?;
interrupt::check()?;
checkout.remove_locked(launch, || {
if token.contents()? != token_contents || record_file.contents()? != record_contents {
bail!("successful review disposal evidence changed; scratch retained");
}
Ok(())
})?;
progress::step(format_args!(
"removed the leftover worktree {}",
path.display()
));
Ok(())
};
inspect_and_remove().map_err(|error| {
let head = Git::new(path)
.run(&["rev-parse", "--verify", "HEAD"])
.ok()
.or(registered_head);
let message = format!(
"retaining checkout {} (expected detached HEAD) at {}: {error:#}",
path.display(),
head.as_deref().unwrap_or("unknown head")
);
error.context(message)
})
}