vivacity_resolver/
metacache.rs1use serde_json::Value;
9use std::path::{Path, PathBuf};
10
11pub struct MetadataCache {
12 root: PathBuf,
13}
14
15fn sanitize_url(url: &str) -> String {
18 static TOKEN: std::sync::OnceLock<pcre2::bytes::Regex> = std::sync::OnceLock::new();
19 static CREDS: std::sync::OnceLock<pcre2::bytes::Regex> = std::sync::OnceLock::new();
20 let token = crate::version::regex(&TOKEN, r"([&?]access_token=)[^&]+", false);
21 let mut out = String::new();
22 let mut last = 0;
23 for m in token.find_iter(url.as_bytes()).flatten() {
24 out.push_str(&url[last..m.start()]);
25 let matched = &url[m.start()..m.end()];
26 let eq = matched.find('=').map(|i| i + 1).unwrap_or(matched.len());
27 out.push_str(&matched[..eq]);
28 out.push_str("***");
29 last = m.end();
30 }
31 out.push_str(&url[last..]);
32 let url = out;
33 let creds = crate::version::regex(
34 &CREDS,
35 r"(?:(?P<prefix>[a-z0-9][a-z0-9+.-]*://)|\A)(?P<user>[^:/\s?#]*)(?::(?P<password>[^\s/?#]+))?@",
36 true,
37 );
38 match creds.captures(url.as_bytes()) {
39 Ok(Some(caps)) => {
40 let whole = caps.get(0).map(|m| (m.start(), m.end())).unwrap_or((0, 0));
41 let prefix = caps.get(1).map(|m| &url[m.start()..m.end()]).unwrap_or("");
42 let user = caps.get(2).map(|m| &url[m.start()..m.end()]).unwrap_or("");
43 let user = sanitize_username(user);
44 let replacement = if caps.get(3).is_some_and(|m| m.end() > m.start()) {
45 format!("{prefix}{user}:***@")
46 } else {
47 format!("{prefix}{user}@")
48 };
49 format!("{}{}{}", &url[..whole.0], replacement, &url[whole.1..])
50 }
51 _ => url,
52 }
53}
54
55fn sanitize_username(user: &str) -> String {
57 const NON_SECRET: &[&str] = &[
58 "gitlab-ci-token",
59 "x-token-auth",
60 "oauth2",
61 "x-oauth-basic",
62 "git",
63 "user",
64 "token",
65 ];
66 static GH: std::sync::OnceLock<pcre2::bytes::Regex> = std::sync::OnceLock::new();
67 if NON_SECRET.contains(&user) {
68 return user.to_owned();
69 }
70 let gh = crate::version::regex(&GH, r"^(?:ghp|gho|ghu|ghs|ghr|github_pat)_", false);
71 if gh.is_match(user.as_bytes()).unwrap_or(false) || user.len() >= 12 {
72 return format!("{}***", &user[..user.len().min(3)]);
73 }
74 user.to_owned()
75}
76
77impl MetadataCache {
78 pub fn new(cache_repo_dir: &Path, repo_url: &str) -> MetadataCache {
80 let slug: String = sanitize_url(repo_url)
81 .chars()
82 .map(|c| {
83 if c.is_ascii_alphanumeric() || c == '.' {
84 c
85 } else {
86 '-'
87 }
88 })
89 .collect();
90 MetadataCache {
91 root: cache_repo_dir.join(slug),
92 }
93 }
94
95 fn path(&self, key: &str) -> PathBuf {
97 let file: String = key
98 .chars()
99 .map(|c| {
100 if c.is_ascii_alphanumeric() || matches!(c, '.' | '$' | '~' | '_') {
101 c
102 } else {
103 '-'
104 }
105 })
106 .collect();
107 self.root.join(file)
108 }
109
110 pub fn provider_key(file_name: &str) -> String {
112 format!("provider-{}.json", file_name.replace('/', "~"))
113 }
114
115 pub fn read(&self, key: &str) -> Option<Vec<u8>> {
116 std::fs::read(self.path(key)).ok()
117 }
118
119 pub fn age(&self, key: &str) -> Option<u64> {
121 let modified = std::fs::metadata(self.path(key)).ok()?.modified().ok()?;
122 std::time::SystemTime::now()
123 .duration_since(modified)
124 .ok()
125 .map(|d| d.as_secs())
126 }
127
128 pub fn write(&self, key: &str, contents: &[u8]) {
131 if std::fs::create_dir_all(&self.root).is_err() {
132 return;
133 }
134 let path = self.path(key);
135 let tmp = path.with_extension(format!("json{:010x}.tmp", std::process::id()));
136 if std::fs::write(&tmp, contents).is_ok() && std::fs::rename(&tmp, &path).is_err() {
137 let _ = std::fs::remove_file(&tmp);
138 }
139 }
140
141 pub fn with_last_modified(data: &Value, last_modified: &str, escaped: bool) -> Option<Vec<u8>> {
147 let mut data = data.clone();
148 let obj = data.as_object_mut()?;
149 obj.insert(
150 "last-modified".into(),
151 Value::String(last_modified.to_owned()),
152 );
153 let opts = vivacity_core::phpjson::EncodeOptions {
154 pretty: false,
155 escape_slashes: escaped,
156 escape_unicode: escaped,
157 };
158 vivacity_core::phpjson::php_json_encode_with(&data, opts)
159 .ok()
160 .map(String::into_bytes)
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn layout_matches_composer() {
170 let c = MetadataCache::new(Path::new("/c/repo"), "https://repo.packagist.org");
171 assert_eq!(c.root, Path::new("/c/repo/https---repo.packagist.org"));
172 assert_eq!(
173 c.path(&MetadataCache::provider_key("acme/lib~dev")),
174 Path::new("/c/repo/https---repo.packagist.org/provider-acme~lib~dev.json")
175 );
176 let c = MetadataCache::new(
177 Path::new("/c/repo"),
178 "https://user:secret@satis.example.org/",
179 );
180 assert_eq!(
181 c.root,
182 Path::new("/c/repo/https---user-----satis.example.org-")
183 );
184 assert_eq!(
185 sanitize_url("https://ghp_abcdefghijklmnop@github.com/x"),
186 "https://ghp***@github.com/x"
187 );
188 assert_eq!(
189 sanitize_url("https://x.org/p?access_token=abc&y=1"),
190 "https://x.org/p?access_token=***&y=1"
191 );
192 let data = serde_json::json!({"packages": {"a/b": []}, "minified": "composer/2.0"});
193 let bytes =
194 MetadataCache::with_last_modified(&data, "Sat, 12 Sep 2026 10:00:00 GMT", false)
195 .unwrap();
196 assert_eq!(
197 String::from_utf8(bytes).unwrap(),
198 r#"{"packages":{"a/b":[]},"minified":"composer/2.0","last-modified":"Sat, 12 Sep 2026 10:00:00 GMT"}"#
199 );
200 let bytes = MetadataCache::with_last_modified(&data, "x", true).unwrap();
201 assert_eq!(
202 String::from_utf8(bytes).unwrap(),
203 r#"{"packages":{"a\/b":[]},"minified":"composer\/2.0","last-modified":"x"}"#
204 );
205 }
206}