use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
const BUNDLED_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/bundled_audited_actions.json"));
const REMOTE_URL: &str = "https://pinprick.rs/audited-actions";
#[derive(Deserialize)]
struct AuditedEntry {
sha: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditSource {
Bundled,
LocalCache,
Remote,
}
impl AuditSource {
pub fn label(&self) -> &'static str {
match self {
Self::Bundled => "bundled",
Self::LocalCache => "local cache",
Self::Remote => "pinprick.rs",
}
}
}
pub struct AuditedActions {
bundled: HashMap<String, HashSet<String>>,
cache_dir: Option<PathBuf>,
client: reqwest::Client,
fetch_remote: bool,
local: HashMap<String, HashSet<String>>,
remote: HashMap<String, HashSet<String>>,
remote_url: String,
}
impl AuditedActions {
pub fn new(fetch_remote: bool) -> Self {
Self {
bundled: load_bundled(),
cache_dir: cache_dir(),
client: crate::github::build_client(),
fetch_remote,
local: HashMap::new(),
remote: HashMap::new(),
remote_url: REMOTE_URL.to_string(),
}
}
pub async fn check(&mut self, owner: &str, repo: &str, sha: &str) -> Option<AuditSource> {
let key = format!("{owner}/{repo}");
if self
.bundled
.get(&key)
.is_some_and(|shas| shas.contains(sha))
{
return Some(AuditSource::Bundled);
}
if !self.local.contains_key(&key) {
let shas = self.load_local_cache(owner, repo);
self.local.insert(key.clone(), shas);
}
if self.local.get(&key).is_some_and(|shas| shas.contains(sha)) {
return Some(AuditSource::LocalCache);
}
if self.fetch_remote {
if !self.remote.contains_key(&key) {
let shas = self.fetch_remote_list(&key).await.unwrap_or_default();
self.remote.insert(key.clone(), shas);
}
if self.remote.get(&key).is_some_and(|shas| shas.contains(sha)) {
return Some(AuditSource::Remote);
}
}
None
}
pub fn cache_clean(&self, owner: &str, repo: &str, sha: &str, tag: &str) {
let Some(cache_dir) = &self.cache_dir else {
return;
};
let Some(path) = cache_path(cache_dir, owner, repo) else {
return;
};
let dir = cache_dir.join(owner);
let mut entries: Vec<serde_json::Value> = std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
if entries
.iter()
.any(|e| e.get("sha").and_then(|s| s.as_str()) == Some(sha))
{
return;
}
entries.push(serde_json::json!({ "sha": sha, "tag": tag }));
if std::fs::create_dir_all(&dir).is_ok()
&& let Some(json) = render_entries(&entries)
{
let _ = std::fs::write(&path, json);
}
}
fn load_local_cache(&self, owner: &str, repo: &str) -> HashSet<String> {
let Some(cache_dir) = &self.cache_dir else {
return HashSet::new();
};
let Some(path) = cache_path(cache_dir, owner, repo) else {
return HashSet::new();
};
let Ok(content) = std::fs::read_to_string(path) else {
return HashSet::new();
};
parse_entries(&content)
}
async fn fetch_remote_list(&self, action_key: &str) -> Option<HashSet<String>> {
let url = format!("{}/{action_key}.json", self.remote_url);
let resp = self
.client
.get(&url)
.header("User-Agent", "pinprick")
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let bytes = crate::github::read_capped(resp).await.ok()?;
Some(parse_entries(&String::from_utf8_lossy(&bytes)))
}
}
fn load_bundled() -> HashMap<String, HashSet<String>> {
let map: HashMap<String, Vec<String>> = serde_json::from_str(BUNDLED_JSON).unwrap_or_default();
map.into_iter()
.map(|(k, v)| (k, v.into_iter().collect()))
.collect()
}
fn parse_entries(json: &str) -> HashSet<String> {
let entries: Vec<AuditedEntry> = serde_json::from_str(json).unwrap_or_default();
entries.into_iter().map(|e| e.sha).collect()
}
fn render_entries(entries: &[serde_json::Value]) -> Option<String> {
serde_json::to_string_pretty(entries)
.ok()
.map(|s| format!("{s}\n"))
}
pub fn cache_dir() -> Option<PathBuf> {
let home = std::env::var("HOME").ok()?;
Some(PathBuf::from(home).join(".cache/pinprick/audited"))
}
fn cache_path(cache_dir: &Path, owner: &str, repo: &str) -> Option<PathBuf> {
(is_safe_segment(owner) && is_safe_segment(repo))
.then(|| cache_dir.join(owner).join(format!("{repo}.json")))
}
fn is_safe_segment(s: &str) -> bool {
!s.is_empty() && s != "." && s != ".." && !s.contains(['/', '\\'])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_segments_accepted() {
for s in ["actions", "checkout", "setup-node", "a.b", "..foo", "v1"] {
assert!(is_safe_segment(s), "{s} should be safe");
}
}
#[test]
fn unsafe_segments_rejected() {
for s in ["", ".", "..", "a/b", "a\\b", "/etc", "..\\.."] {
assert!(!is_safe_segment(s), "{s} should be rejected");
}
}
#[test]
fn cache_path_stays_inside_cache_dir() {
let base = Path::new("/cache");
assert_eq!(
cache_path(base, "actions", "checkout"),
Some(PathBuf::from("/cache/actions/checkout.json"))
);
assert_eq!(cache_path(base, "..", "checkout"), None);
assert_eq!(cache_path(base, "actions", "../../etc/passwd"), None);
assert_eq!(cache_path(base, "", "checkout"), None);
}
#[test]
fn render_entries_round_trips() {
let entries = vec![
serde_json::json!({ "sha": "aaa", "tag": "v1" }),
serde_json::json!({ "sha": "bbb", "tag": "v2" }),
];
let rendered = render_entries(&entries).unwrap();
assert!(rendered.ends_with('\n'));
let shas = parse_entries(&rendered);
assert!(shas.contains("aaa"));
assert!(shas.contains("bbb"));
}
#[test]
fn render_entries_escapes_adversarial_tag() {
let entries = vec![serde_json::json!({
"sha": "abc123",
"tag": r#"v1 "stable" \ release"#,
})];
let rendered = render_entries(&entries).unwrap();
let parsed: Vec<serde_json::Value> =
serde_json::from_str(&rendered).expect("rendered cache must be valid JSON");
assert_eq!(parsed[0]["sha"], "abc123");
assert_eq!(parsed[0]["tag"], r#"v1 "stable" \ release"#);
assert!(parse_entries(&rendered).contains("abc123"));
}
mod remote {
use super::*;
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn fetch_remote_list_parses_entries() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/actions/checkout.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{ "sha": "aaa", "tag": "v1" },
{ "sha": "bbb", "tag": "v2" }
])))
.mount(&server)
.await;
let mut aa = AuditedActions::new(true);
aa.remote_url = server.uri();
let shas = aa.fetch_remote_list("actions/checkout").await.unwrap();
assert!(shas.contains("aaa"));
assert!(shas.contains("bbb"));
}
#[tokio::test]
async fn fetch_remote_list_non_success_is_none() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/actions/missing.json"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let mut aa = AuditedActions::new(true);
aa.remote_url = server.uri();
assert!(aa.fetch_remote_list("actions/missing").await.is_none());
}
#[tokio::test]
async fn check_falls_through_to_remote_layer() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/some/action.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{ "sha": "feedface", "tag": "v3" }
])))
.mount(&server)
.await;
let mut aa = AuditedActions::new(true);
aa.remote_url = server.uri();
aa.cache_dir = None; assert_eq!(
aa.check("some", "action", "feedface").await,
Some(AuditSource::Remote)
);
assert_eq!(aa.check("some", "action", "0000").await, None);
}
}
}