use super::fs_beneath::ObjectId;
use super::subvolume_error::MigrateError;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
pub const JOURNAL_NAME: &str = "cli-git-convert";
pub const LOCK_NAME: &str = "cli-git-convert.lock";
const SMALL_FILE_LIMIT: u64 = 1 << 20;
const JOURNAL_HEADER: &str = "cli-git-convert v1";
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum Phase {
Started,
Creating,
Synced,
Exchanged,
Refreshed,
Removed,
}
impl Phase {
fn name(self) -> &'static str {
return match self {
Phase::Started => "started",
Phase::Creating => "creating",
Phase::Synced => "synced",
Phase::Exchanged => "exchanged",
Phase::Refreshed => "refreshed",
Phase::Removed => "removed",
};
}
fn parse(text: &str) -> Option<Phase> {
return [Phase::Started, Phase::Creating, Phase::Synced, Phase::Exchanged, Phase::Refreshed, Phase::Removed]
.into_iter()
.find(|phase: &Phase| { return phase.name() == text; });
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Journal {
pub phase: Phase,
pub token: String,
pub path: Vec<u8>,
pub old_identity: ObjectId,
pub git_file: Vec<u8>,
pub new_identity: Option<ObjectId>,
}
fn hex(bytes: &[u8]) -> String {
return bytes.iter().map(|byte: &u8| { return format!("{byte:02x}"); }).collect();
}
fn unhex(text: &str) -> Option<Vec<u8>> {
if text.len() % 2 != 0 {
return None;
}
return (0..text.len()).step_by(2).map(|at: usize| { return u8::from_str_radix(text.get(at..at + 2)?, 16).ok(); }).collect();
}
fn temporary_name() -> String {
let nanos: u128 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |elapsed: std::time::Duration| { return elapsed.as_nanos(); });
return format!("{JOURNAL_NAME}.tmp.{}.{nanos}", std::process::id());
}
impl Journal {
fn encode(&self) -> String {
return format!(
"{JOURNAL_HEADER}\nphase={}\ntoken={}\npath={}\nold_identity={}\ngit_file={}\nnew_identity={}\n",
self.phase.name(),
self.token,
hex(self.path.as_slice()),
self.old_identity.encode(),
hex(self.git_file.as_slice()),
self.new_identity.map_or_else(|| { return String::from("-"); }, |identity: ObjectId| { return identity.encode(); })
);
}
fn decode(text: &str) -> Result<Journal, MigrateError> {
let bad = || { return MigrateError::Journal(String::from("the conversion journal has a shape this command does not know; nothing was changed")); };
let mut lines = text.lines();
if lines.next() != Some(JOURNAL_HEADER) {
return Err(bad());
}
let field = |name: &str| -> Option<String> { return text.lines().find_map(|line: &str| { return line.strip_prefix(name).and_then(|rest: &str| { return rest.strip_prefix('='); }).map(String::from); }); };
let new_identity: Option<ObjectId> = match field("new_identity").as_deref() {
None | Some("-") => None,
Some(encoded) => Some(ObjectId::decode(encoded).ok_or_else(bad)?),
};
let old_identity: ObjectId = match field("old_identity") {
Some(encoded) => ObjectId::decode(encoded.as_str()).ok_or_else(bad)?,
None => ObjectId {
device: field("old_device").and_then(|digits: String| { return digits.parse().ok(); }).ok_or_else(bad)?,
inode: field("old_inode").and_then(|digits: String| { return digits.parse().ok(); }).ok_or_else(bad)?,
birth: None,
},
};
return Ok(Journal {
phase: Phase::parse(field("phase").ok_or_else(bad)?.as_str()).ok_or_else(bad)?,
token: field("token").ok_or_else(bad)?,
path: unhex(field("path").ok_or_else(bad)?.as_str()).ok_or_else(bad)?,
old_identity,
git_file: unhex(field("git_file").ok_or_else(bad)?.as_str()).ok_or_else(bad)?,
new_identity,
});
}
fn write_temporary(&self, admin: &Path) -> std::io::Result<PathBuf> {
let temporary: PathBuf = admin.join(temporary_name());
let mut file: std::fs::File = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&temporary)?;
let written: std::io::Result<()> = file.write_all(self.encode().as_bytes()).and_then(|()| { return file.sync_all(); });
if let Err(error) = written {
let _removed: std::io::Result<()> = std::fs::remove_file(&temporary);
return Err(error);
}
return Ok(temporary);
}
pub fn save(&self, admin: &Path) -> Result<(), MigrateError> {
let target: PathBuf = admin.join(JOURNAL_NAME);
let written: std::io::Result<()> = (|| {
let temporary: PathBuf = self.write_temporary(admin)?;
if let Err(error) = std::fs::rename(&temporary, &target) {
let _removed: std::io::Result<()> = std::fs::remove_file(&temporary);
return Err(error);
}
return std::fs::File::open(admin).and_then(|directory: std::fs::File| { return directory.sync_all(); });
})();
return written.map_err(|error: std::io::Error| { return MigrateError::io("write the conversion journal", target.as_path(), &error); });
}
pub fn create_new(&self, admin: &Path) -> Result<(), MigrateError> {
let target: PathBuf = admin.join(JOURNAL_NAME);
let written: std::io::Result<()> = (|| {
let temporary: PathBuf = self.write_temporary(admin)?;
let linked: std::io::Result<()> = std::fs::hard_link(&temporary, &target);
let _removed: std::io::Result<()> = std::fs::remove_file(&temporary);
linked?;
return std::fs::File::open(admin).and_then(|directory: std::fs::File| { return directory.sync_all(); });
})();
return written.map_err(|error: std::io::Error| { return MigrateError::io("create the conversion journal", target.as_path(), &error); });
}
pub fn load(admin: &Path) -> Result<Option<Journal>, MigrateError> {
let path: PathBuf = admin.join(JOURNAL_NAME);
match read_plain_file(path.as_path(), SMALL_FILE_LIMIT) {
Ok(bytes) => return Journal::decode(String::from_utf8_lossy(bytes.as_slice()).as_ref()).map(Some),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(MigrateError::io("read the conversion journal", path.as_path(), &error)),
}
}
}
pub fn read_plain_file(path: &Path, limit: u64) -> std::io::Result<Vec<u8>> {
let file: std::fs::File = std::fs::OpenOptions::new().read(true).custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW | libc::O_CLOEXEC).open(path)?;
if !file.metadata()?.file_type().is_file() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "it is not a plain file (a named pipe, a device or a directory)"));
}
let mut bytes: Vec<u8> = Vec::new();
std::io::Read::read_to_end(&mut std::io::Read::take(&file, limit + 1), &mut bytes)?;
if bytes.len() as u64 > limit {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "it is larger than this command reads"));
}
return Ok(bytes);
}
pub fn read_plain_text(path: &Path) -> std::io::Result<String> {
return String::from_utf8(read_plain_file(path, SMALL_FILE_LIMIT)?).map_err(|_error| { return std::io::Error::new(std::io::ErrorKind::InvalidData, "it is not text"); });
}
pub fn is_recorded_object(recorded: &ObjectId, found: &ObjectId) -> bool {
return recorded.device == found.device && recorded.inode == found.inode && (recorded.birth.is_none() || recorded.birth == found.birth);
}
#[derive(Debug)]
pub struct ConversionLock {
_file: std::fs::File,
}
impl ConversionLock {
pub fn acquire(admin: &Path) -> Result<Option<ConversionLock>, MigrateError> {
let path: PathBuf = admin.join(LOCK_NAME);
let file: std::fs::File = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(&path)
.map_err(|error: std::io::Error| { return MigrateError::io("open the conversion lock", path.as_path(), &error); })?;
let plain: bool = file.metadata().map_err(|error: std::io::Error| { return MigrateError::io("examine the conversion lock", path.as_path(), &error); })?.file_type().is_file();
if !plain {
return Err(MigrateError::Journal(format!("{} is not a plain file, so it cannot serve as the conversion lock; nothing was changed", path.display())));
}
let result: libc::c_int = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if result == 0 {
return Ok(Some(ConversionLock { _file: file }));
}
let error: std::io::Error = std::io::Error::last_os_error();
if error.kind() == std::io::ErrorKind::WouldBlock {
return Ok(None);
}
return Err(MigrateError::io("take the conversion lock", path.as_path(), &error));
}
}