use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const LOCK_FILE: &str = "ai.lock";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Lockfile {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub id: String,
#[serde(default)]
pub skills: BTreeMap<String, LockedSkill>,
}
pub fn generate_id(root: &Path) -> String {
let seed = format!(
"{:?}|{:?}|{}",
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH),
root,
std::process::id()
);
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in seed.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("spm-{:08x}", h & 0xffff_ffff)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockedSkill {
pub git: String,
pub reference: String,
pub commit: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub path: Option<String>,
pub store: String,
}
impl Lockfile {
pub fn path_in(dir: &Path) -> PathBuf {
dir.join(LOCK_FILE)
}
pub fn load_or_default(dir: &Path) -> Result<Self> {
let p = Self::path_in(dir);
if !p.exists() {
return Ok(Self::default());
}
let text = std::fs::read_to_string(&p)?;
let lock: Self =
serde_json::from_str(&text).with_context(|| format!("parsing {}", p.display()))?;
lock.validate()
.with_context(|| format!("in {}", p.display()))?;
Ok(lock)
}
fn validate(&self) -> Result<()> {
if !self.id.is_empty() {
validate_project_id(&self.id)?;
}
for (name, l) in &self.skills {
validate_commit(&l.commit).with_context(|| format!("skill `{name}`"))?;
validate_store_key(&l.store).with_context(|| format!("skill `{name}`"))?;
let expected = store_key(&l.git, &l.commit);
if l.store != expected {
bail!(
"skill `{name}`: store key `{}` does not match `{expected}` derived from git+commit",
l.store
);
}
}
Ok(())
}
pub fn save(&self, dir: &Path) -> Result<()> {
let p = Self::path_in(dir);
let text = serde_json::to_string_pretty(self)?;
std::fs::write(&p, text + "\n").with_context(|| format!("writing {}", p.display()))
}
}
pub fn validate_project_id(id: &str) -> Result<()> {
let ok = !id.is_empty()
&& id != "."
&& id != ".."
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if !ok {
bail!("invalid project id `{id}`: expected `[A-Za-z0-9_-]+`");
}
Ok(())
}
pub fn validate_commit(commit: &str) -> Result<()> {
let ok = commit.len() == 40
&& commit
.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c));
if !ok {
bail!("invalid commit `{commit}`: expected a full 40-character lowercase hex SHA");
}
Ok(())
}
pub fn validate_store_key(key: &str) -> Result<()> {
let bad = key.is_empty()
|| key.contains('/')
|| key.contains('\\')
|| key.contains('\0')
|| key.split('@').any(|seg| seg == "." || seg == "..");
if bad {
bail!("invalid store key `{key}`: must be a single path component");
}
Ok(())
}
pub fn store_key(url: &str, sha: &str) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in url.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
let hint: String = url
.trim_end_matches('/')
.rsplit(['/', ':'])
.next()
.unwrap_or(url)
.trim_end_matches(".git")
.chars()
.filter(char::is_ascii_alphanumeric)
.take(32)
.collect();
format!("{hint}-{h:016x}@{sha}")
}
#[cfg(test)]
mod tests {
use super::{store_key, validate_store_key};
#[test]
fn store_key_is_bounded_safe_and_stable() {
let sha = "0123456789abcdef0123456789abcdef01234567";
let long = format!(
"file://{}/skill",
"C_Users_runner_AppData_Local_Temp".repeat(20)
);
let key = store_key(&long, sha);
validate_store_key(&key).expect("generated key must pass validation");
assert!(key.len() <= 90, "key too long: {} ({})", key.len(), key);
assert!(key.ends_with(&format!("@{sha}")));
assert_eq!(key, store_key(&long, sha));
assert_ne!(
store_key("https://github.com/a/repo", sha),
store_key("https://github.com/b/repo", sha)
);
}
}