use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct F2ReceiptKey {
pub model_sha256: String,
pub apr_version: String,
pub device: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct F2Receipt {
pub schema: u32,
#[serde(flatten)]
pub key: F2ReceiptKey,
pub validated_at: u64,
pub positions_judged: usize,
}
pub const F2_RECEIPT_SCHEMA: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum F2ValidateReason {
NoReceipt,
Unreadable(String),
SchemaMismatch {
found: u32,
expected: u32,
},
ModelSha256Mismatch {
found: String,
expected: String,
},
AprVersionMismatch {
found: String,
expected: String,
},
DeviceMismatch {
found: String,
expected: String,
},
Revalidate,
}
impl std::fmt::Display for F2ValidateReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoReceipt => write!(f, "no receipt for this model"),
Self::Unreadable(e) => write!(f, "receipt unreadable ({e})"),
Self::SchemaMismatch { found, expected } => {
write!(f, "receipt schema {found}, this build writes {expected}")
},
Self::ModelSha256Mismatch { found, expected } => write!(
f,
"receipt is for model {}…, this file is {}…",
&found[..found.len().min(12)],
&expected[..expected.len().min(12)]
),
Self::AprVersionMismatch { found, expected } => {
write!(f, "receipt written by apr {found}, this is {expected}")
},
Self::DeviceMismatch { found, expected } => {
write!(f, "receipt written for {found}, this device is {expected}")
},
Self::Revalidate => write!(f, "--revalidate"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum F2Decision {
Skip {
receipt: F2Receipt,
},
Validate(F2ValidateReason),
}
#[must_use]
pub fn decide(
found: Result<Option<F2Receipt>, String>,
expected: &F2ReceiptKey,
revalidate: bool,
) -> F2Decision {
if revalidate {
return F2Decision::Validate(F2ValidateReason::Revalidate);
}
let receipt = match found {
Err(e) => return F2Decision::Validate(F2ValidateReason::Unreadable(e)),
Ok(None) => return F2Decision::Validate(F2ValidateReason::NoReceipt),
Ok(Some(r)) => r,
};
if receipt.schema != F2_RECEIPT_SCHEMA {
return F2Decision::Validate(F2ValidateReason::SchemaMismatch {
found: receipt.schema,
expected: F2_RECEIPT_SCHEMA,
});
}
if receipt.key.model_sha256 != expected.model_sha256 {
return F2Decision::Validate(F2ValidateReason::ModelSha256Mismatch {
found: receipt.key.model_sha256,
expected: expected.model_sha256.clone(),
});
}
if receipt.key.apr_version != expected.apr_version {
return F2Decision::Validate(F2ValidateReason::AprVersionMismatch {
found: receipt.key.apr_version,
expected: expected.apr_version.clone(),
});
}
if receipt.key.device != expected.device {
return F2Decision::Validate(F2ValidateReason::DeviceMismatch {
found: receipt.key.device,
expected: expected.device.clone(),
});
}
F2Decision::Skip { receipt }
}
#[must_use]
pub fn receipt_dir() -> Option<PathBuf> {
if let Some(d) = std::env::var_os("APR_F2_RECEIPT_DIR") {
return Some(PathBuf::from(d));
}
if let Some(x) = std::env::var_os("XDG_CACHE_HOME") {
if !x.is_empty() {
return Some(PathBuf::from(x).join("apr").join("f2-receipts"));
}
}
std::env::var_os("HOME").map(|h| {
PathBuf::from(h)
.join(".cache")
.join("apr")
.join("f2-receipts")
})
}
#[must_use]
pub fn receipt_path(dir: &Path, model_sha256: &str) -> PathBuf {
dir.join(format!("{model_sha256}.json"))
}
pub fn read_receipt(path: &Path) -> Result<Option<F2Receipt>, String> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("{}: {e}", path.display())),
};
serde_json::from_str::<F2Receipt>(&text)
.map(Some)
.map_err(|e| format!("{}: not a receipt: {e}", path.display()))
}
pub fn write_receipt(path: &Path, receipt: &F2Receipt) -> Result<(), String> {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let dir = path
.parent()
.ok_or_else(|| format!("{}: no parent directory", path.display()))?;
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let stem = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("receipt");
let tmp = dir.join(format!(
".{stem}.{}.{}.tmp",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
let body = serde_json::to_string_pretty(receipt).map_err(|e| e.to_string())?;
if let Err(e) = std::fs::write(&tmp, body) {
return Err(format!("{}: {e}", tmp.display()));
}
std::fs::rename(&tmp, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
format!("{}: {e}", path.display())
})
}
#[must_use]
pub fn model_sha256(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(bytes);
let out = h.finalize();
let mut s = String::with_capacity(64);
for b in out {
use std::fmt::Write as _;
let _ = write!(s, "{b:02x}");
}
s
}
#[must_use]
pub fn apr_version() -> String {
let exe = std::env::current_exe().and_then(std::fs::read).ok();
build_identity(env!("CARGO_PKG_VERSION"), exe.as_deref())
}
#[must_use]
pub fn build_identity(version: &str, exe: Option<&[u8]>) -> String {
match exe {
Some(bytes) => format!("{version} exe:{}", &model_sha256(bytes)[..16]),
None => {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
format!("{version} exe:unreadable-{}-{nanos}", std::process::id())
},
}
}
#[must_use]
pub fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
#[must_use]
pub fn revalidate_requested() -> bool {
std::env::var("APR_F2_REVALIDATE").is_ok_and(|v| v == "1")
}
#[cfg(test)]
#[path = "f2_receipt_tests.rs"]
mod f2_receipt_tests;