Skip to main content

pacenotes_server/store/
manifest_store.rs

1/// The published manifest, reloaded when `manifest.json`'s mtime changes.
2pub struct ManifestStore {
3    data_dir: std::path::PathBuf,
4    cache: std::sync::RwLock<CachedManifest>,
5}
6
7struct CachedManifest {
8    manifest: pacenotes_shared::Manifest,
9    mtime: Option<std::time::SystemTime>,
10    is_loaded: bool,
11}
12
13impl ManifestStore {
14    pub fn new(data_dir: impl Into<std::path::PathBuf>) -> Self {
15        Self {
16            data_dir: data_dir.into(),
17            cache: std::sync::RwLock::new(CachedManifest {
18                manifest: pacenotes_shared::Manifest::default(),
19                mtime: None,
20                is_loaded: false,
21            }),
22        }
23    }
24
25    pub fn manifest_path(&self) -> std::path::PathBuf {
26        self.data_dir.join(crate::store::MANIFEST_FILE)
27    }
28
29    /// A missing manifest is an empty one; an unreadable one keeps the last
30    /// good copy serving, because a bad publish must not fail requests.
31    pub fn current(&self) -> pacenotes_shared::Manifest {
32        let disk_mtime = std::fs::metadata(self.manifest_path())
33            .and_then(|metadata| metadata.modified())
34            .ok();
35        {
36            let cached = self.cache.read().unwrap_or_else(|e| e.into_inner());
37            if cached.is_loaded && cached.mtime == disk_mtime {
38                return cached.manifest.clone();
39            }
40        }
41
42        match crate::store::manifest_file::read_manifest(&self.manifest_path()) {
43            Ok(manifest) => {
44                let mut cached = self.cache.write().unwrap_or_else(|e| e.into_inner());
45                cached.manifest = manifest.clone();
46                cached.mtime = disk_mtime;
47                cached.is_loaded = true;
48                manifest
49            }
50            Err(e) => {
51                tracing::error!(
52                    error = %e,
53                    path = %self.manifest_path().display(),
54                    "manifest load failed"
55                );
56                self.cache
57                    .read()
58                    .unwrap_or_else(|e| e.into_inner())
59                    .manifest
60                    .clone()
61            }
62        }
63    }
64
65    /// The zip behind a manifest entry; the URL's `game`, `directory` and
66    /// `name` are only ever lookup keys, so nothing unlisted can resolve.
67    pub fn zip_path(
68        &self,
69        game: &str,
70        directory: &str,
71        name: &str,
72    ) -> Option<(pacenotes_shared::PackageInfo, std::path::PathBuf)> {
73        let package = self.current().packages.into_iter().find(|package| {
74            package.game == game && package.directory == directory && package.name == name
75        })?;
76        let path = zip_file(&self.data_dir, &package)?;
77        Some((package, path))
78    }
79
80    pub fn motd(&self, lang: &str) -> Option<String> {
81        crate::store::motd::read_motd(&self.data_dir, lang)
82    }
83}
84
85/// Where a listed package's zip lives; `None` unless every key is valid, so a
86/// hand-edited manifest cannot name a path outside the data root.
87pub fn zip_file(
88    data_dir: &std::path::Path,
89    package: &pacenotes_shared::PackageInfo,
90) -> Option<std::path::PathBuf> {
91    if !pacenotes_shared::valid_id(&package.game)
92        || !pacenotes_shared::valid_id(&package.directory)
93        || !pacenotes_shared::valid_name(&package.name)
94    {
95        return None;
96    }
97    let path = data_dir
98        .join(&package.game)
99        .join(crate::store::PACKAGES_DIR)
100        .join(&package.directory)
101        .join(format!("{}.zip", package.name));
102    Some(path)
103}
104
105#[cfg(test)]
106mod tests {
107    #[test]
108    fn reloads_on_mtime_change_and_resolves_paths() {
109        let tmp = tempfile::tempdir().unwrap();
110        let dir = tmp.path();
111        crate::store::fixture_tree::write_package(dir, "rbr", "public/basic.zip", b"hello");
112        let store = super::ManifestStore::new(dir);
113        assert!(store.current().packages.is_empty());
114
115        crate::store::write_manifest(dir, &crate::store::fixture_tree::generate(dir).manifest)
116            .unwrap();
117
118        assert_eq!(store.current().packages.len(), 1);
119        let (package, path) = store.zip_path("rbr", "public", "basic").unwrap();
120        assert_eq!(package.name, "basic");
121        assert_eq!(
122            path,
123            crate::store::fixture_tree::package_path(dir, "rbr", "public/basic.zip")
124        );
125        assert!(store.zip_path("rbr", "public", "nope").is_none());
126        assert!(store.zip_path("rbr", "../data", "auth").is_none());
127    }
128
129    #[test]
130    fn nested_names_resolve_back_to_their_zip() {
131        let tmp = tempfile::tempdir().unwrap();
132        let dir = tmp.path();
133        let kamil = crate::store::fixture_tree::package_path(
134            dir,
135            "rbr",
136            "public/codriver/polish/male/Kamil.zip",
137        );
138        crate::store::fixture_tree::write_package(
139            dir,
140            "rbr",
141            "public/codriver/polish/male/Kamil.zip",
142            b"kamil",
143        );
144        crate::store::write_manifest(dir, &crate::store::fixture_tree::generate(dir).manifest)
145            .unwrap();
146
147        let store = super::ManifestStore::new(dir);
148        let (package, path) = store
149            .zip_path("rbr", "public", "codriver/polish/male/Kamil")
150            .unwrap();
151        assert_eq!(package.leaf(), "Kamil");
152        assert_eq!(path, kamil);
153
154        for not_a_package in [
155            "codriver/polish/male",
156            "codriver",
157            "codriver/../../manifest",
158        ] {
159            assert!(
160                store.zip_path("rbr", "public", not_a_package).is_none(),
161                "{not_a_package}"
162            );
163        }
164    }
165
166    #[test]
167    fn a_bad_manifest_keeps_the_last_good_copy_serving() {
168        let tmp = tempfile::tempdir().unwrap();
169        let dir = tmp.path();
170        crate::store::fixture_tree::write_package(dir, "rbr", "public/basic.zip", b"hello");
171        crate::store::write_manifest(dir, &crate::store::fixture_tree::generate(dir).manifest)
172            .unwrap();
173        let store = super::ManifestStore::new(dir);
174        assert_eq!(store.current().packages.len(), 1);
175
176        crate::store::fixture_tree::write(&dir.join(crate::store::MANIFEST_FILE), b"{ not json");
177        assert_eq!(store.current().packages.len(), 1);
178    }
179}