use crate::kept_names_in_the_git_directories::path_for_reading;
use crate::private_storage::{create_private_directory_all, write_private_file_unsynced};
use crate::push_failure_notice::fnv_hex;
use crate::service_instance::instance_of;
use crate::work_item::WORK_DIRECTORY;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::path::{Path, PathBuf};
use std::time::Duration;
pub const WATCH_DIRECTORY: &str = "claim-watch";
const WATCH_EXTENSION: &str = "watch";
const FORMAT_PREFIX: &str = "claim-watch-1 ";
pub type ReadFile<'a> = &'a dyn Fn(&Path) -> std::io::Result<Vec<u8>>;
#[derive(Debug, Default)]
pub struct ClaimWatch {
store: Option<PathBuf>,
instance: Option<String>,
known: Vec<PathBuf>,
unwritten: Vec<PathBuf>,
unread: bool,
failures: u32,
}
fn hex_digit_value(digit: char) -> Option<u32> {
return digit.to_digit(16);
}
fn byte_of_digit_pair(pair: &[u32]) -> Option<u8> {
return u8::try_from(pair[0] * 16 + pair[1]).ok();
}
fn hex_of_byte(byte: &u8) -> String {
return format!("{byte:02x}");
}
fn read_file(path: &Path) -> std::io::Result<Vec<u8>> {
return std::fs::read(path);
}
fn decode(bytes: &[u8]) -> Result<PathBuf, String> {
let text: &str = match std::str::from_utf8(bytes) {
Ok(found) => found,
Err(_not_text) => return Err(String::from("the file is not text")),
};
let Some(after_prefix) = text.strip_prefix(FORMAT_PREFIX) else {
return Err(String::from("the file does not start with the format words or lacks its last newline"));
};
let Some(hex) = after_prefix.strip_suffix('\n') else {
return Err(String::from("the file does not start with the format words or lacks its last newline"));
};
if hex.len() % 2 != 0 || !hex.as_bytes().iter().all(u8::is_ascii_hexdigit) {
return Err(String::from("the path is not written in hexadecimal"));
}
let digits: Vec<u32> = hex.chars().filter_map(hex_digit_value).collect();
let raw: Vec<u8> = digits.chunks(2).filter_map(byte_of_digit_pair).collect();
let path: PathBuf = PathBuf::from(std::ffi::OsString::from_vec(raw));
if !path.is_absolute() || path.as_os_str().as_bytes().contains(&0) {
return Err(String::from("the path is not absolute"));
}
return Ok(path);
}
fn encode(git_dir: &Path) -> String {
let hex: String = git_dir.as_os_str().as_bytes().iter().map(hex_of_byte).collect();
return format!("{FORMAT_PREFIX}{hex}\n");
}
fn watch_name(git_dir: &Path, attempt: u32) -> String {
return format!("{}-{attempt}.{WATCH_EXTENSION}", fnv_hex(encode(git_dir).as_str()));
}
impl ClaimWatch {
pub fn in_memory() -> Self {
return Self::default();
}
pub fn restored(directory: &Path, instance: Option<&str>) -> Self {
return Self::restored_reading(directory, instance, &read_file);
}
pub fn restored_reading(directory: &Path, instance: Option<&str>, read: ReadFile<'_>) -> Self {
let mut watch: Self = Self { store: Some(directory.to_path_buf()), instance: instance.map(str::to_string), ..Self::default() };
let _found: Vec<PathBuf> = watch.scan(read);
return watch;
}
pub fn directories(&self) -> Vec<PathBuf> {
return self.known.clone();
}
pub fn key(&self) -> Option<PathBuf> {
return self.store.clone();
}
pub fn needs_retry(&self) -> bool {
return self.store.is_some() && (self.unread || !self.unwritten.is_empty());
}
pub fn pause(&self, first: Duration) -> Duration {
return crate::push_retry::pause_before(self.failures.max(1), first);
}
fn is_foreign(&self, git_dir: &Path) -> bool {
return match self.instance.as_deref() {
Some(mine) => instance_of(git_dir) != mine,
None => false,
};
}
pub fn remember(&mut self, git_dir: &Path) {
for known in self.known.iter().chain(self.unwritten.iter()) {
if known == git_dir {
return;
}
}
if self.is_foreign(git_dir) {
crate::service_warn!("{} belongs to another instance and is not kept for a restart", git_dir.display());
return;
}
let Some(directory) = self.store.clone() else {
self.known.push(git_dir.to_path_buf());
return;
};
match write_down(directory.as_path(), git_dir) {
Ok(()) => self.known.push(git_dir.to_path_buf()),
Err(reason) => {
crate::service_warn!("{} could not be kept for a restart, so it is tried again after a pause: {reason}", git_dir.display());
self.unwritten.push(git_dir.to_path_buf());
self.failures = self.failures.saturating_add(1);
}
}
}
pub fn retry(&mut self) -> Vec<PathBuf> {
return self.retry_reading(&read_file);
}
pub fn retry_reading(&mut self, read: ReadFile<'_>) -> Vec<PathBuf> {
let Some(directory) = self.store.clone() else { return Vec::new() };
for git_dir in std::mem::take(&mut self.unwritten) {
match write_down(directory.as_path(), git_dir.as_path()) {
Ok(()) => self.known.push(git_dir),
Err(reason) => {
crate::service_warn!("{} still could not be kept for a restart: {reason}", git_dir.display());
self.unwritten.push(git_dir);
}
}
}
let found: Vec<PathBuf> = if self.unread { self.scan(read) } else { Vec::new() };
if self.needs_retry() {
self.failures = self.failures.saturating_add(1);
} else {
self.failures = 0;
}
return found;
}
fn scan(&mut self, read: ReadFile<'_>) -> Vec<PathBuf> {
let Some(directory) = self.store.clone() else { return Vec::new() };
self.unread = false;
let listing = match std::fs::read_dir(directory.as_path()) {
Ok(listing) => listing,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(error) => {
crate::service_warn!("the notes of the repositories to look at in {} could not be listed, so they are listed again after a pause: {error}", directory.display());
self.unread = true;
return Vec::new();
}
};
let mut found: Vec<PathBuf> = Vec::new();
for listed in listing {
let path: PathBuf = match listed {
Ok(entry) => entry.path(),
Err(error) => {
crate::service_warn!("an entry of {} could not be listed, so the notes are listed again after a pause: {error}", directory.display());
self.unread = true;
continue;
}
};
if path.extension().and_then(std::ffi::OsStr::to_str) != Some(WATCH_EXTENSION) {
continue;
}
let git_dir: PathBuf = match read(path.as_path()) {
Ok(bytes) => match decode(bytes.as_slice()) {
Ok(git_dir) => git_dir,
Err(reason) => {
crate::service_warn!("the note {} holds no repository and is removed: {reason}", path.display());
let _removed = std::fs::remove_file(path.as_path());
continue;
}
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
crate::service_warn!("the note {} could not be read, so it is read again after a pause: {error}", path.display());
self.unread = true;
continue;
}
};
if self.is_foreign(git_dir.as_path()) {
crate::service_warn!("the note {} names {}, which is not this instance's repository, and is removed", path.display(), git_dir.display());
let _removed = std::fs::remove_file(path.as_path());
continue;
}
match std::fs::symlink_metadata(path_for_reading(git_dir.as_path(), WORK_DIRECTORY)) {
Ok(metadata) if metadata.is_dir() => {
if !self.known.contains(&git_dir) {
self.known.push(git_dir.clone());
found.push(git_dir);
}
}
Ok(_) => {
crate::service_warn!("the work directory of {} is not a directory, so its note {} is removed", git_dir.display(), path.display());
let _removed = std::fs::remove_file(path.as_path());
}
Err(error) if matches!(error.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory) => {
let _removed = std::fs::remove_file(path.as_path());
}
Err(error) => {
crate::service_warn!("the work directory of {} could not be looked at, so its note is kept and read again after a pause: {error}", git_dir.display());
self.unread = true;
}
}
}
return found;
}
}
fn write_down(directory: &Path, git_dir: &Path) -> Result<(), String> {
if let Err(error) = create_private_directory_all(directory) {
return Err(format!("creating the note directory failed: {error}"));
}
let text: String = encode(git_dir);
let mut attempt: u32 = 0;
loop {
let path: PathBuf = directory.join(watch_name(git_dir, attempt));
match write_private_file_unsynced(path.as_path(), text.as_bytes()) {
Ok(()) => return Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => match std::fs::read(path.as_path()) {
Ok(held) if held == text.as_bytes() => return Ok(()),
Ok(held) if decode(held.as_slice()).is_ok() => attempt = attempt.saturating_add(1),
Ok(_) => {
if let Err(removal) = std::fs::remove_file(path.as_path()) {
return Err(format!("removing the damaged note {} failed: {removal}", path.display()));
}
}
Err(read_error) => return Err(format!("reading the note {} failed: {read_error}", path.display())),
},
Err(error) => return Err(format!("writing the note failed: {error}")),
}
}
}
#[cfg(test)]
#[path = "service_claim_watch_tests.rs"]
mod tests;