advent_of_code_data/
cache.rs

1use std::{
2    fmt::Debug,
3    path::{Path, PathBuf},
4};
5
6use base64::{prelude::BASE64_STANDARD, Engine};
7use core::str;
8use simple_crypt::{decrypt, encrypt};
9use thiserror::Error;
10
11use crate::{
12    data::{Answers, Puzzle, Session},
13    Day, Part, Year,
14};
15
16/// Represents an error occurring when interacting with the cache.
17#[derive(Debug, Error)]
18pub enum CacheError {
19    #[error("passphrase expected but not provided (check your config)")]
20    PassphraseRequired,
21    #[error("Cached input file is not encrypted but encryption passphrase was provided")]
22    // TODO: Should this be a warning and not an error?
23    PassphraseNotNeeded,
24    #[error("base 64 decoding failed: {}", .0)]
25    DecodeBase64(#[from] base64::DecodeError),
26    #[error("decryption failed: {}", .0)]
27    Decryption(#[source] anyhow::Error),
28    #[error("encryption failed: {}", .0)]
29    Encryption(#[source] anyhow::Error),
30    #[error("decoding utf8 failed: {}", .0)]
31    DecodeUtf8(#[from] std::string::FromUtf8Error),
32    #[error("serializing or deserializing failed: {}", .0)]
33    JsonSerde(#[from] serde_json::Error),
34    #[error("a file i/o error occured while reading/writing the cache: {}", .0)]
35    Io(#[from] std::io::Error),
36    #[error("an error occurred while parsing a cached answer dataset: {}", .0)]
37    AnswerParsing(#[from] crate::data::AnswerDeserializationError),
38}
39
40/// Caches puzzle inputs and answers to allow retrieval without having to request data from the
41/// Advent of Code service.
42///
43/// Input data should be encrypted when written to storage, as requested by the Advent of Code
44/// owner. Answers do not need to be encrypted.
45pub trait PuzzleCache: Debug {
46    /// Load input for the given day and year. Returns the decrypted input if cached, or `Ok(None)`
47    /// if no cache entry exists.
48    fn load_input(&self, day: Day, year: Year) -> Result<Option<String>, CacheError>;
49
50    /// Load answers for the given part, day and year. Returns `Ok(None)` if no cache entry exists.
51    fn load_answers(&self, part: Part, day: Day, year: Year)
52        -> Result<Option<Answers>, CacheError>;
53
54    /// Save a puzzle's input and answers for both parts to the cache.
55    fn save(&self, puzzle: Puzzle) -> Result<(), CacheError> {
56        self.save_input(&puzzle.input, puzzle.day, puzzle.year)?;
57        self.save_answers(&puzzle.part_one_answers, Part::One, puzzle.day, puzzle.year)?;
58        self.save_answers(&puzzle.part_two_answers, Part::Two, puzzle.day, puzzle.year)?;
59
60        Ok(())
61    }
62
63    /// Save input for the given day and year. The input is encrypted before being written to disk
64    /// if a passphrase is provided. Any previously saved input for this day and year will be
65    /// overwritten.
66    fn save_input(&self, input: &str, day: Day, year: Year) -> Result<(), CacheError>;
67
68    /// Save answers for the given part, day and year. Any previously saved answers for this day and
69    /// year will be overwritten.
70    fn save_answers(
71        &self,
72        answers: &Answers,
73        part: Part,
74        day: Day,
75        year: Year,
76    ) -> Result<(), CacheError>;
77}
78
79/// Stores cached data specific to a session, such as submission timeouts.
80pub trait SessionCache: Debug {
81    /// Load session data linked to a session from the cache.
82    fn load(&self, session_id: &str) -> Result<Session, CacheError> {
83        if let Some(session) = self.try_load(session_id)? {
84            Ok(session)
85        } else {
86            tracing::debug!("session {session_id} was not cached; returning new Session object");
87            Ok(Session::new(session_id))
88        }
89    }
90
91    /// Load session data linked to a session from the cache. Returns `Ok(None)` if there is no
92    /// existing cached session linked to `session_id`.
93    fn try_load(&self, session_id: &str) -> Result<Option<Session>, CacheError>;
94
95    /// Writes session data to the cache.
96    fn save(&self, session: &Session) -> Result<(), CacheError>;
97}
98
99/// A file system backed implementation of `PuzzleCache`.
100///
101/// Cached puzzle data is grouped together by day and year into a directory. The cache layout
102/// follows this general pattern:
103///
104///    <cache_dir>/y<year>/<day>/input.encrypted.txt
105///                             /part-1-answers.txt
106///                             /part-2-answers.txt
107///
108/// `cache_dir` is specified when `PuzzleFsCache::new(...)` is called.
109/// `year` is four digit puzzle year.
110/// `day` is the puzzle day with no leading zeroes, and starting from index one.
111///
112/// **Encryption**: If a passphrase is configured, inputs are automatically encrypted when saved and
113/// decrypted when loaded. The `.encrypted.txt` suffix indicates an encrypted file. Unencrypted
114/// input files use the `.txt` extension.
115#[derive(Debug)]
116pub struct PuzzleFsCache {
117    cache_dir: PathBuf,
118    passphrase: Option<String>,
119}
120
121impl PuzzleFsCache {
122    const INPUT_FILE_NAME: &'static str = "input.txt";
123    const ENCRYPTED_INPUT_FILE_NAME: &'static str = "input.encrypted.txt";
124    const PART_ONE_ANSWERS_FILE_NAME: &'static str = "part-1-answers.txt";
125    const PART_TWO_ANSWERS_FILE_NAME: &'static str = "part-2-answers.txt";
126
127    /// Creates a new `PuzzleFsCache` that reads/writes cache data stored in `cache_dir`. Inputs are
128    /// are encrypted on disk using the provided passphrase.
129    pub fn new<P: Into<PathBuf>, S: Into<String>>(cache_dir: P, passphrase: Option<S>) -> Self {
130        Self {
131            cache_dir: cache_dir.into(),
132            passphrase: passphrase.map(|x| x.into()),
133        }
134    }
135
136    /// Get the directory path for a puzzle day and year.
137    pub fn dir_for_puzzle(cache_dir: &Path, day: Day, year: Year) -> PathBuf {
138        cache_dir.join(format!("y{}", year)).join(day.to_string())
139    }
140
141    /// Returns the file path for puzzle input, with the appropriate extension based on encryption status.
142    pub fn input_file_path(cache_dir: &Path, day: Day, year: Year, encrypted: bool) -> PathBuf {
143        Self::dir_for_puzzle(cache_dir, day, year).join(if encrypted {
144            Self::ENCRYPTED_INPUT_FILE_NAME
145        } else {
146            Self::INPUT_FILE_NAME
147        })
148    }
149
150    /// Returns the file path for answers of a given part.
151    pub fn answers_file_path(cache_dir: &Path, part: Part, day: Day, year: Year) -> PathBuf {
152        Self::dir_for_puzzle(cache_dir, day, year).join(match part {
153            Part::One => Self::PART_ONE_ANSWERS_FILE_NAME,
154            Part::Two => Self::PART_TWO_ANSWERS_FILE_NAME,
155        })
156    }
157}
158
159impl PuzzleCache for PuzzleFsCache {
160    fn load_input(&self, day: Day, year: Year) -> Result<Option<String>, CacheError> {
161        // Check for common encryption misconfiguration scenarios and warn or return an error
162        // depending on severity.
163        let using_encryption = self.passphrase.is_some();
164        let input_path = Self::input_file_path(&self.cache_dir, day, year, using_encryption);
165        let input_path_exists = std::fs::exists(&input_path).unwrap_or(false);
166
167        let alt_input_path = Self::input_file_path(&self.cache_dir, day, year, !using_encryption);
168        let alt_input_path_exists = std::fs::exists(alt_input_path).unwrap_or(false);
169
170        match (using_encryption, input_path_exists, alt_input_path_exists) {
171            (true, true, true) => {
172                tracing::warn!(
173                    "mixed input (encrypted and unencrypted) for year {year} day {day} found in cache"
174                );
175            }
176            (true, false, true) => return Err(CacheError::PassphraseNotNeeded),
177            (false, true, true) => {
178                tracing::warn!(
179                    "mixed input (encrypted and unencrypted) input for year {year} day {day} found in cache"
180                );
181            }
182            (false, false, true) => return Err(CacheError::PassphraseRequired),
183            (_, false, _) => return Ok(None),
184            _ => {}
185        }
186
187        // Read the cached input file.
188        tracing::debug!("loading input for day {day} year {year} from {input_path:?}");
189
190        match std::fs::read_to_string(input_path) {
191            Ok(input_text) => {
192                // Check if the input file needs to be decrypted before returning it.
193                if let Some(passphrase) = &self.passphrase {
194                    // Input needs decryption before it can be returned.
195                    let encrypted_bytes = BASE64_STANDARD
196                        .decode(input_text.as_bytes())
197                        .map_err(CacheError::DecodeBase64)?;
198                    let input_bytes = decrypt(&encrypted_bytes, passphrase.as_bytes())
199                        .map_err(CacheError::Decryption)?;
200                    let decrypted_input_text =
201                        String::from_utf8(input_bytes).map_err(CacheError::DecodeUtf8)?;
202
203                    tracing::debug!("succesfully decrypted input for puzzle day {day} year {year}");
204
205                    Ok(Some(decrypted_input_text))
206                } else {
207                    // Input does not need decryption.
208                    Ok(Some(input_text))
209                }
210            }
211            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
212            Err(e) => Err(CacheError::Io(e)),
213        }
214    }
215
216    fn load_answers(
217        &self,
218        part: Part,
219        day: Day,
220        year: Year,
221    ) -> Result<Option<Answers>, CacheError> {
222        match std::fs::read_to_string(Self::answers_file_path(&self.cache_dir, part, day, year)) {
223            Ok(answers_data) => Ok(Some(Answers::deserialize_from_str(&answers_data)?)),
224            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
225            Err(e) => Err(CacheError::Io(e)),
226        }
227    }
228
229    fn save(&self, puzzle: Puzzle) -> Result<(), CacheError> {
230        // Create the puzzle directory in the cache if it doesn't already exist.
231        let puzzle_dir = Self::dir_for_puzzle(&self.cache_dir, puzzle.day, puzzle.year);
232        std::fs::create_dir_all(puzzle_dir)?;
233
234        self.save_input(&puzzle.input, puzzle.day, puzzle.year)?;
235        self.save_answers(&puzzle.part_one_answers, Part::One, puzzle.day, puzzle.year)?;
236        self.save_answers(&puzzle.part_two_answers, Part::Two, puzzle.day, puzzle.year)?;
237
238        Ok(())
239    }
240
241    fn save_input(&self, input: &str, day: Day, year: Year) -> Result<(), CacheError> {
242        // Calculate the path to the puzzle's input file.
243        let input_path =
244            Self::input_file_path(&self.cache_dir, day, year, self.passphrase.is_some());
245
246        // Create puzzle directory if it does not already exist.
247        let mut puzzle_dir = input_path.clone();
248        puzzle_dir.pop();
249
250        std::fs::create_dir_all(puzzle_dir)?;
251
252        // Write the input to disk and encrypt the input file when stored on disk.
253        if let Some(passphrase) = &self.passphrase {
254            // Encrypt then base64 encode for better version control handling.
255            let encrypted_data =
256                encrypt(input.as_bytes(), passphrase.as_bytes()).map_err(CacheError::Encryption)?;
257            let b64_encrypted_text = BASE64_STANDARD.encode(encrypted_data);
258
259            tracing::debug!("saving encrypted input for day {day} year {year} to {input_path:?}");
260            Ok(std::fs::write(input_path, b64_encrypted_text)?)
261        } else {
262            // No encryption.
263            tracing::debug!("saving unencrypted input for day {day} year {year} to {input_path:?}");
264            Ok(std::fs::write(input_path, input)?)
265        }
266    }
267
268    fn save_answers(
269        &self,
270        answers: &Answers,
271        part: Part,
272        day: Day,
273        year: Year,
274    ) -> Result<(), CacheError> {
275        let answers_path = Self::answers_file_path(&self.cache_dir, part, day, year);
276
277        // Create the puzzle directory if it doesn't already exist.
278        let mut puzzle_dir = answers_path.clone();
279        puzzle_dir.pop();
280
281        std::fs::create_dir_all(puzzle_dir)?;
282
283        tracing::debug!("saving answer for part {part} day {day} year {year} to {answers_path:?}");
284        Ok(std::fs::write(answers_path, answers.serialize_to_string())?)
285    }
286}
287
288#[derive(Debug)]
289pub struct SessionFsCache {
290    cache_dir: PathBuf,
291}
292
293impl SessionFsCache {
294    pub fn new<P: Into<PathBuf>>(cache_dir: P) -> Self {
295        Self {
296            cache_dir: cache_dir.into(),
297        }
298    }
299
300    /// Returns the cache file path for session data. The session ID is used directly as the filename.
301    /// Note: Assumes the session ID is already sanitized and safe for use as a filename.
302    pub fn session_data_filepath(&self, session_id: &str) -> PathBuf {
303        self.cache_dir.join(format!("{}.json", session_id))
304    }
305}
306
307impl SessionCache for SessionFsCache {
308    fn try_load(&self, session_id: &str) -> Result<Option<Session>, CacheError> {
309        let session_filepath = self.session_data_filepath(session_id);
310
311        if session_filepath.is_file() {
312            tracing::debug!("cached session data for {session_id} is at `{session_filepath:?}`");
313
314            let json_text = std::fs::read_to_string(session_filepath)?;
315            let session: Session = serde_json::from_str(&json_text)?;
316
317            Ok(Some(session))
318        } else {
319            tracing::debug!("session file `{session_filepath:?}` for {session_id} does not exist");
320            Ok(None)
321        }
322    }
323
324    fn save(&self, session: &Session) -> Result<(), CacheError> {
325        let session_filepath = self.session_data_filepath(&session.session_id);
326
327        // Create puzzle directory if it does not already exist.
328        let mut session_dir = session_filepath.clone();
329        session_dir.pop();
330
331        std::fs::create_dir_all(session_dir)?;
332
333        // Write the serialized session data to disk.
334        let json_text = serde_json::to_string(&session)?;
335        tracing::debug!("saving session data to `{session_filepath:?}`");
336
337        std::fs::write(session_filepath, json_text)?;
338        Ok(())
339    }
340}