Skip to main content

vivacity_resolver/
metacache.rs

1//! Metadata cache of a `composer` repository, in Composer's format and
2//! location (`Cache` on `cache-repo-dir/<sanitized url>/`): same file names
3//! (`packages.json`, `provider-<vendor>~<name>[~dev].json`), same content
4//! (the JSON re-encoded with `last-modified` injected when the server sent
5//! the header, the raw body otherwise). A cache written by one is read by
6//! the other, and vice versa.
7
8use serde_json::Value;
9use std::path::{Path, PathBuf};
10
11pub struct MetadataCache {
12    root: PathBuf,
13}
14
15/// `Url::sanitize` reduced to credentials in the URL (`user:pass@`) and the
16/// `access_token` parameter.
17fn 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
55/// `Url::sanitizeUsername`.
56fn 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    /// `new Cache($io, $config->get('cache-repo-dir').'/'.preg_replace('{[^a-z0-9.]}i', '-', Url::sanitize($url)))`.
79    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    /// Sanitized key (`Cache`: `[^a-z0-9.$~_]` -> `-`).
96    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    /// `provider-<name with / -> ~>.json`.
111    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    /// `Cache::getAge`: age of the file in seconds.
120    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    /// Atomic write (temp + rename), like `Cache::write`; a failure is
129    /// silent (Composer carries on without cache).
130    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    /// The content to write when the server provided `Last-Modified`:
142    /// `$data['last-modified'] = ...` then compact `JsonFile::encode`, with
143    /// `JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE` for package files
144    /// (`asyncFetchFile`), with flags 0 (slashes and unicode escaped) for
145    /// `packages.json` and the `includes` (`fetchFile`).
146    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}