Skip to main content

qcode/provider/
file.rs

1//! `providers.toml`: the one file a key is written into, and the checks made every time it is
2//! read.
3//!
4//! It is not the settings file and it never will be. Settings are copied between machines, sent
5//! to someone who is helping, and pasted into a report; a key that lived there would travel with
6//! them. This file lives in QCode's own data folder, it is written with mode 600 in a folder of
7//! mode 700, and how wide its permissions really are is checked on every read and said out loud
8//! when they are wider than that.
9//!
10//! Nothing here repairs the file and nothing panics over it. A provider that cannot be read is
11//! reported with the line and column where it went wrong and left out of the list; the providers
12//! around it are still returned. A lineup is flat like the rest of the file — one row per step,
13//! naming the provider, the lineup and the model — so that a step which cannot be read falls on
14//! its own and the steps below it are read anyway.
15
16use std::path::{Path, PathBuf};
17
18use qframe::diagnostics::{Diagnostic, Location};
19use qframe::document::{Document, Shape, ValueKind};
20use qframe::storage::data_dir;
21
22use crate::store::Loaded;
23
24use super::{Key, Lineup, Measured, Model, Price, ProviderEntry, ProviderKind, Tag, Wire, record::trim_base};
25
26/// QCode's folder under the platform's data directory: `<XDG_DATA_HOME>/quvyta/code` on Linux.
27const APP: &str = "quvyta/code";
28
29/// The file itself, under that folder.
30const FILE_NAME: &str = "providers.toml";
31
32/// The mode the folder is created with and checked against.
33#[cfg(unix)]
34const FOLDER_MODE: u32 = 0o700;
35
36/// The mode the file is created with and checked against.
37#[cfg(unix)]
38const FILE_MODE: u32 = 0o600;
39
40/// The key of the array of providers.
41const PROVIDER: &str = "provider";
42/// The key of the array of models, which is flat and names its provider, so that a model of a
43/// provider that could not be read is simply left where it is.
44const MODEL: &str = "model";
45/// The key of the array of lineup steps. It is flat and names its provider too, and it is read
46/// after the models so that a step is not lost when a provider above it could not be read.
47const LINEUP: &str = "lineup";
48/// The key of a provider's tag, and of the tag a model belongs to.
49const TAG: &str = "tag";
50/// The key of a provider's kind.
51const KIND: &str = "kind";
52/// The key of a provider's base address.
53const BASE: &str = "base";
54/// The key of a provider's wire format.
55const WIRE: &str = "wire";
56/// The key of a provider's key.
57const KEY: &str = "key";
58/// The key of a model's name.
59const ID: &str = "id";
60/// The key of the window a model's own record claims.
61const CLAIMED: &str = "claimed-context";
62/// The key of the window the server was seen to accept.
63const MEASURED: &str = "measured-context";
64/// The key of how that measurement ended: at the window's edge, or still growing.
65const MEASURED_KIND: &str = "measured";
66/// The key of what using a model costs, written only where the service says.
67const PRICE: &str = "price";
68/// The key of a lineup's name.
69const NAME: &str = "name";
70/// The key of the model one step of a lineup tries.
71const STEP: &str = "model";
72
73/// The providers a person has added, and where they are kept.
74#[derive(Debug, Clone, PartialEq, Default)]
75pub struct Providers {
76    path: Option<PathBuf>,
77    entries: Vec<ProviderEntry>,
78}
79
80/// Why a provider could not be added or renamed.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum AddError {
83    /// A provider of this tag is already there. Two providers of one tag would make the tag in
84    /// front of a model name mean two different machines.
85    Taken(String),
86}
87
88impl Providers {
89    /// Providers that live only in memory; saving does nothing. For tests, and for a machine
90    /// with no data folder at all.
91    #[must_use]
92    pub fn in_memory() -> Self {
93        Self::default()
94    }
95
96    /// Where the providers file goes on this machine, or `None` on one whose data folder cannot
97    /// be worked out.
98    #[must_use]
99    pub fn file() -> Option<PathBuf> {
100        data_dir(APP).map(|dir| dir.join(FILE_NAME))
101    }
102
103    /// Reads the providers of this machine, with everything that was wrong with the file. How
104    /// wide its permissions are is asked separately, of [`permission_problems`].
105    #[must_use]
106    pub fn load() -> Loaded<Self> {
107        match Self::file() {
108            Some(path) => Self::open(&path),
109            None => Loaded { value: Self::in_memory(), diagnostics: Vec::new() },
110        }
111    }
112
113    /// Reads the providers at `path`. A file that is not there yet is an empty list, not a
114    /// problem: nobody has added a provider.
115    #[must_use]
116    pub fn open(path: &Path) -> Loaded<Self> {
117        let text = match std::fs::read_to_string(path) {
118            Ok(text) => text,
119            Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
120            Err(error) => {
121                let diagnostics = vec![Diagnostic::error(None, format!("{}: {error}", path.display()))];
122                return Loaded { value: Self { path: Some(path.to_path_buf()), entries: Vec::new() }, diagnostics };
123            }
124        };
125        let name = path.file_name().map_or_else(|| FILE_NAME.to_owned(), |name| name.to_string_lossy().into_owned());
126        let mut loaded = Self::parse(&name, &text);
127        loaded.value.path = Some(path.to_path_buf());
128        loaded
129    }
130
131    /// Reads providers from TOML `text`, reporting problems against `file`. Saving does nothing.
132    #[must_use]
133    pub fn parse(file: &str, text: &str) -> Loaded<Self> {
134        let document = Document::parse(file, text, &shape());
135        let mut diagnostics = document.diagnostics().to_vec();
136        let root = document.root();
137        let mut entries: Vec<ProviderEntry> = Vec::new();
138
139        for table in root.entries(PROVIDER) {
140            let at = |key: &str| table.value_location(key).cloned();
141            let Some(written) = table.text(TAG) else { continue };
142            let tag = match Tag::parse(written) {
143                Ok(tag) => tag,
144                Err(problem) => {
145                    let message = format!("`{TAG}` is `{written}`, which is not a tag: {problem:?}");
146                    diagnostics.push(Diagnostic::error(at(TAG), message));
147                    continue;
148                }
149            };
150            if entries.iter().any(|entry| entry.tag == tag) {
151                let message = format!("`{TAG}` is `{tag}`, and a provider of that tag was already read");
152                diagnostics.push(Diagnostic::error(at(TAG), message));
153                continue;
154            }
155            // A kind or a base the file does not hold is already reported by the document; a
156            // provider without either cannot be asked anything, so it is left out.
157            let (Some(kind), Some(base)) = (table.text(KIND).and_then(ProviderKind::parse), table.text(BASE)) else {
158                continue;
159            };
160            let wire = match table.text(WIRE) {
161                Some(written) => Wire::parse(written).unwrap_or_else(|| kind.wire()),
162                None => kind.wire(),
163            };
164            let key = table.text(KEY).and_then(Key::new);
165            if table.text(KEY).is_some() && key.is_none() {
166                let message = format!("`{KEY}` of `{tag}` is not a key that fits a request header; it is not used");
167                diagnostics.push(Diagnostic::warning(at(KEY), message));
168            }
169            entries.push(ProviderEntry {
170                tag,
171                kind,
172                base: trim_base(base),
173                wire,
174                models: Vec::new(),
175                key,
176                lineups: Vec::new(),
177            });
178        }
179
180        for table in root.entries(MODEL) {
181            let at = |key: &str| table.value_location(key).cloned();
182            let (Some(tag), Some(id)) = (table.text(TAG), table.text(ID)) else { continue };
183            let Some(entry) = entries.iter_mut().find(|entry| entry.tag.as_str() == tag) else {
184                let message = format!("`{TAG}` is `{tag}`, and no provider of that tag was read; the model is dropped");
185                diagnostics.push(Diagnostic::warning(at(TAG), message));
186                continue;
187            };
188            let claimed = positive(table.integer(CLAIMED), CLAIMED, at(CLAIMED), &mut diagnostics);
189            let tokens = positive(table.integer(MEASURED), MEASURED, at(MEASURED), &mut diagnostics);
190            // A measured window is two values that only mean anything together: the number, and
191            // whether that number is the edge or only as far as anyone looked.
192            let measured = match (tokens, table.text(MEASURED_KIND)) {
193                (Some(tokens), Some(written)) => match Measured::parse(written, tokens) {
194                    Some(measured) => Some(measured),
195                    None => {
196                        let message = format!("`{MEASURED_KIND}` is `{written}`; the measurement is dropped");
197                        diagnostics.push(Diagnostic::warning(at(MEASURED_KIND), message));
198                        None
199                    }
200                },
201                (Some(_), None) => {
202                    let message =
203                        format!("`{MEASURED}` is there without `{MEASURED_KIND}`; the measurement is dropped");
204                    diagnostics.push(Diagnostic::warning(at(MEASURED), message));
205                    None
206                }
207                (None, _) => None,
208            };
209            // A price the service never gave is left out rather than filled in, so that a model
210            // of a provider that publishes no prices never carries one.
211            let price = table.text(PRICE).and_then(Price::parse);
212            entry.models.push(Model { id: id.to_owned(), claimed, measured, price });
213        }
214
215        // After the models, so that a step can already be read against the provider's own list —
216        // and read against it without judging it: a step naming a model the provider was not last
217        // seen to have is kept, because the listing may simply be older than the order.
218        for table in root.entries(LINEUP) {
219            let at = |key: &str| table.value_location(key).cloned();
220            let (Some(tag), Some(written), Some(step)) = (table.text(TAG), table.text(NAME), table.text(STEP)) else {
221                continue;
222            };
223            let Some(entry) = entries.iter_mut().find(|entry| entry.tag.as_str() == tag) else {
224                let message = format!("`{TAG}` is `{tag}`, and no provider of that tag was read; the step is dropped");
225                diagnostics.push(Diagnostic::warning(at(TAG), message));
226                continue;
227            };
228            let name = match Tag::parse(written) {
229                Ok(name) => name,
230                Err(problem) => {
231                    let message =
232                        format!("`{NAME}` is `{written}`, which is not a tag: {problem:?}; the step is dropped");
233                    diagnostics.push(Diagnostic::warning(at(NAME), message));
234                    continue;
235                }
236            };
237            let step = step.to_owned();
238            // A step whose model is already in this order would send the same request twice and
239            // fall back to it twice, so the second is dropped where it stands.
240            match entry.lineups.iter_mut().find(|lineup| lineup.name == name) {
241                Some(lineup) if lineup.models.contains(&step) => {
242                    let message = format!("`{STEP}` is `{step}`, and it is already in `{name}`; the step is dropped");
243                    diagnostics.push(Diagnostic::warning(at(STEP), message));
244                }
245                Some(lineup) => lineup.models.push(step),
246                None => entry.lineups.push(Lineup { name, models: vec![step] }),
247            }
248        }
249
250        Loaded { value: Self { path: None, entries }, diagnostics }
251    }
252
253    /// The providers, in the order they were added.
254    #[must_use]
255    pub fn entries(&self) -> &[ProviderEntry] {
256        &self.entries
257    }
258
259    /// The provider tagged `tag`.
260    #[must_use]
261    pub fn get(&self, tag: &str) -> Option<&ProviderEntry> {
262        self.entries.iter().find(|entry| entry.tag.as_str() == tag)
263    }
264
265    /// Adds `entry` at the end of the list.
266    ///
267    /// # Errors
268    ///
269    /// [`AddError::Taken`] when a provider of that tag is already there.
270    pub fn add(&mut self, entry: ProviderEntry) -> Result<(), AddError> {
271        if self.entries.iter().any(|already| already.tag == entry.tag) {
272            return Err(AddError::Taken(entry.tag.as_str().to_owned()));
273        }
274        self.entries.push(entry);
275        Ok(())
276    }
277
278    /// Replaces what is known about the provider tagged `tag`, when it is still there.
279    pub fn replace(&mut self, tag: &str, entry: ProviderEntry) {
280        if let Some(slot) = self.entries.iter_mut().find(|already| already.tag.as_str() == tag) {
281            *slot = entry;
282        }
283    }
284
285    /// Removes the provider tagged `tag`, and with it the key it was reached with. Whether
286    /// anything was there is the answer.
287    pub fn remove(&mut self, tag: &str) -> bool {
288        let before = self.entries.len();
289        self.entries.retain(|entry| entry.tag.as_str() != tag);
290        before != self.entries.len()
291    }
292
293    /// Forgets the key of the provider tagged `tag`, leaving the provider itself where it is.
294    /// This is a separate thing from removing the provider, because a person who has rolled
295    /// their key wants the tag, the address and the measurements to stay.
296    pub fn forget_key(&mut self, tag: &str) -> bool {
297        match self.entries.iter_mut().find(|entry| entry.tag.as_str() == tag) {
298            Some(entry) => entry.key.take().is_some(),
299            None => false,
300        }
301    }
302
303    /// Puts `lineup` on the provider tagged `tag`, in place of the lineup of the same name or at
304    /// the end of its list. Editing an order keeps it where it was, so that a list of orders does
305    /// not jump around under the person who is working through it.
306    pub fn set_lineup(&mut self, tag: &str, lineup: Lineup) {
307        let Some(entry) = self.entries.iter_mut().find(|entry| entry.tag.as_str() == tag) else { return };
308        match entry.lineups.iter_mut().find(|already| already.name == lineup.name) {
309            Some(slot) => *slot = lineup,
310            None => entry.lineups.push(lineup),
311        }
312    }
313
314    /// Removes the lineup called `name` from the provider tagged `tag`. Whether anything was there
315    /// is the answer, which is what the page asks before it says the order is gone.
316    pub fn remove_lineup(&mut self, tag: &str, name: &str) -> bool {
317        match self.entries.iter_mut().find(|entry| entry.tag.as_str() == tag) {
318            Some(entry) => {
319                let before = entry.lineups.len();
320                entry.lineups.retain(|lineup| lineup.name.as_str() != name);
321                before != entry.lineups.len()
322            }
323            None => false,
324        }
325    }
326
327    /// The file as it is written.
328    #[must_use]
329    pub fn to_toml(&self) -> String {
330        let mut text = String::new();
331        for entry in &self.entries {
332            text.push_str(&format!("[[{PROVIDER}]]\n"));
333            text.push_str(&format!("{TAG} = {}\n", quote(entry.tag.as_str())));
334            text.push_str(&format!("{KIND} = {}\n", quote(entry.kind.id())));
335            text.push_str(&format!("{BASE} = {}\n", quote(&entry.base)));
336            text.push_str(&format!("{WIRE} = {}\n", quote(entry.wire.id())));
337            if let Some(key) = &entry.key {
338                text.push_str(&format!("{KEY} = {}\n", quote(key.expose())));
339            }
340            text.push('\n');
341        }
342        for entry in &self.entries {
343            for model in &entry.models {
344                text.push_str(&format!("[[{MODEL}]]\n"));
345                text.push_str(&format!("{TAG} = {}\n", quote(entry.tag.as_str())));
346                text.push_str(&format!("{ID} = {}\n", quote(&model.id)));
347                if let Some(claimed) = model.claimed {
348                    text.push_str(&format!("{CLAIMED} = {claimed}\n"));
349                }
350                if let Some(measured) = model.measured {
351                    text.push_str(&format!("{MEASURED} = {}\n", measured.tokens()));
352                    text.push_str(&format!("{MEASURED_KIND} = {}\n", quote(measured.id())));
353                }
354                if let Some(price) = model.price {
355                    text.push_str(&format!("{PRICE} = {}\n", quote(price.id())));
356                }
357                text.push('\n');
358            }
359        }
360        // After the models, one block per step, so that the file reads the way the order does:
361        // which lineup a step belongs to is told by its name, and the names repeat down the file.
362        for entry in &self.entries {
363            for lineup in &entry.lineups {
364                for step in &lineup.models {
365                    text.push_str(&format!("[[{LINEUP}]]\n"));
366                    text.push_str(&format!("{TAG} = {}\n", quote(entry.tag.as_str())));
367                    text.push_str(&format!("{NAME} = {}\n", quote(lineup.name.as_str())));
368                    text.push_str(&format!("{STEP} = {}\n", quote(step)));
369                    text.push('\n');
370                }
371            }
372        }
373        text
374    }
375
376    /// Writes the file, creating its folder with mode 700 and the file itself with mode 600. A
377    /// folder that was already there, made wider by something else, is narrowed to 700 too: the
378    /// key is about to be in it.
379    ///
380    /// The file is written beside its place and moved onto it, so a write that stops halfway
381    /// leaves the providers that were there rather than half a file. The temporary file is made
382    /// with the narrow mode too: a key must never exist, even for an instant, in a file anyone
383    /// else on the machine can open.
384    ///
385    /// # Errors
386    ///
387    /// What the file system said, when the folder or the file could not be written.
388    pub fn save(&self) -> Result<(), String> {
389        let Some(path) = &self.path else { return Ok(()) };
390        let folder = path.parent().ok_or_else(|| format!("{}: it has no folder", path.display()))?;
391        create_folder(folder).map_err(|error| format!("{}: {error}", folder.display()))?;
392        let temporary = path.with_extension("toml.writing");
393        write_narrow(&temporary, &self.to_toml()).map_err(|error| format!("{}: {error}", temporary.display()))?;
394        std::fs::rename(&temporary, path).map_err(|error| {
395            let _ = std::fs::remove_file(&temporary);
396            format!("{}: {error}", path.display())
397        })
398    }
399
400    /// Where these providers are kept, for the line the page shows.
401    #[must_use]
402    pub fn path(&self) -> Option<&Path> {
403        self.path.as_deref()
404    }
405
406    /// The same providers, kept at `path`. For a test that writes a file of its own.
407    #[must_use]
408    pub fn at(mut self, path: impl Into<PathBuf>) -> Self {
409        self.path = Some(path.into());
410        self
411    }
412}
413
414/// Reads an integer that only means anything above zero, reporting one that is not.
415fn positive(value: Option<i64>, key: &str, at: Option<Location>, diagnostics: &mut Vec<Diagnostic>) -> Option<u64> {
416    match value {
417        None => None,
418        Some(number) if number > 0 => u64::try_from(number).ok(),
419        Some(number) => {
420            diagnostics.push(Diagnostic::warning(at, format!("`{key}` is {number}; a window is a count of tokens")));
421            None
422        }
423    }
424}
425
426/// What a providers file may hold.
427fn shape() -> Shape {
428    let provider = Shape::new()
429        .required(TAG, ValueKind::text())
430        .required(KIND, ValueKind::choice(ProviderKind::ALL.map(ProviderKind::id)))
431        .required(BASE, ValueKind::text())
432        .optional(WIRE, ValueKind::choice(Wire::ALL.map(Wire::id)))
433        .optional(KEY, ValueKind::text());
434    let model = Shape::new()
435        .required(TAG, ValueKind::text())
436        .required(ID, ValueKind::text())
437        .optional(CLAIMED, ValueKind::integer())
438        .optional(MEASURED, ValueKind::integer())
439        .optional(MEASURED_KIND, ValueKind::choice(["about", "at-least"]))
440        .optional(PRICE, ValueKind::choice(Price::ALL.map(Price::id)));
441    let lineup = Shape::new()
442        .required(TAG, ValueKind::text())
443        .required(NAME, ValueKind::text())
444        .required(STEP, ValueKind::text());
445    Shape::new().entries(PROVIDER, provider).entries(MODEL, model).entries(LINEUP, lineup)
446}
447
448/// A TOML string, with what TOML cannot hold plain written the way TOML writes it.
449fn quote(text: &str) -> String {
450    let mut quoted = String::with_capacity(text.len() + 2);
451    quoted.push('"');
452    for character in text.chars() {
453        match character {
454            '"' => quoted.push_str("\\\""),
455            '\\' => quoted.push_str("\\\\"),
456            '\n' => quoted.push_str("\\n"),
457            '\r' => quoted.push_str("\\r"),
458            '\t' => quoted.push_str("\\t"),
459            other if (other as u32) < 0x20 => quoted.push_str(&format!("\\u{:04X}", other as u32)),
460            other => quoted.push(other),
461        }
462    }
463    quoted.push('"');
464    quoted
465}
466
467/// A place that lets others on this machine read what is in it.
468#[derive(Debug, Clone, PartialEq, Eq)]
469pub struct PermissionProblem {
470    /// The providers file, or its folder.
471    pub place: PathBuf,
472    /// Its permissions as they are, such as `0o755`.
473    pub mode: u32,
474    /// What they should be.
475    pub wanted: u32,
476}
477
478/// What is wrong with how wide the permissions of `path` and its folder are, read from the disk
479/// as it is now.
480///
481/// This is a warning and never a refusal to read: a person whose backup program widened the
482/// folder still wants their providers, and what they need is to be told. While there is no file
483/// there is no key to protect, so the folder, which other parts of QCode make too, is not asked
484/// about; the first save narrows it.
485#[must_use]
486pub fn permission_problems(path: &Path) -> Vec<PermissionProblem> {
487    #[cfg(unix)]
488    {
489        use std::os::unix::fs::PermissionsExt as _;
490
491        if !path.exists() {
492            return Vec::new();
493        }
494        let mut problems = Vec::new();
495        let mut check = |place: &Path, wanted: u32| {
496            let Ok(metadata) = std::fs::metadata(place) else { return };
497            let mode = metadata.permissions().mode() & 0o777;
498            if mode & !wanted != 0 {
499                problems.push(PermissionProblem { place: place.to_path_buf(), mode, wanted });
500            }
501        };
502        if let Some(folder) = path.parent() {
503            check(folder, FOLDER_MODE);
504        }
505        check(path, FILE_MODE);
506        problems
507    }
508    #[cfg(not(unix))]
509    {
510        let _ = path;
511        Vec::new()
512    }
513}
514
515/// Creates `folder` and everything above it, with the folder itself narrow.
516fn create_folder(folder: &Path) -> std::io::Result<()> {
517    #[cfg(unix)]
518    {
519        use std::os::unix::fs::DirBuilderExt as _;
520
521        if let Some(above) = folder.parent() {
522            std::fs::create_dir_all(above)?;
523        }
524        match std::fs::DirBuilder::new().mode(FOLDER_MODE).create(folder) {
525            Ok(()) => Ok(()),
526            // Made before, perhaps by another part of QCode with the usual 755; the key is about
527            // to be written into it, so it is narrowed now rather than warned about afterwards.
528            // A folder that is not the person's to narrow keeps its mode and the file is still
529            // written: the page reads the permissions again after a save and says what is wrong.
530            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
531                use std::os::unix::fs::PermissionsExt as _;
532                let mode = std::fs::metadata(folder)?.permissions().mode() & 0o777;
533                if mode & !FOLDER_MODE != 0 {
534                    let _ = std::fs::set_permissions(folder, std::fs::Permissions::from_mode(mode & FOLDER_MODE));
535                }
536                Ok(())
537            }
538            Err(error) => Err(error),
539        }
540    }
541    #[cfg(not(unix))]
542    std::fs::create_dir_all(folder)
543}
544
545/// Writes `text` to `path` in a file only its owner can open.
546fn write_narrow(path: &Path, text: &str) -> std::io::Result<()> {
547    use std::io::Write as _;
548
549    let mut options = std::fs::OpenOptions::new();
550    options.write(true).create(true).truncate(true);
551    #[cfg(unix)]
552    {
553        use std::os::unix::fs::OpenOptionsExt as _;
554        options.mode(FILE_MODE);
555    }
556    let mut file = options.open(path)?;
557    file.write_all(text.as_bytes())?;
558    file.sync_all()
559}