Skip to main content

onenote_cli/
config.rs

1use crate::{cli::InitArgs, error::Error};
2use serde::{Deserialize, Serialize};
3use std::{
4    collections::BTreeMap,
5    fs::{self, File, OpenOptions},
6    io::{Read, Write},
7    path::PathBuf,
8};
9
10#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
11#[serde(rename_all = "lowercase")]
12pub enum Backend {
13    #[default]
14    Desktop,
15    Ssh,
16}
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19#[serde(default, deny_unknown_fields)]
20pub struct Profile {
21    pub backend: Backend,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub host: Option<String>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub identity_file: Option<PathBuf>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub port: Option<u16>,
28    pub read_only: bool,
29}
30impl Profile {
31    pub fn from_args(args: &InitArgs) -> Result<Self, Error> {
32        let mut profile = Self {
33            backend: args.backend,
34            host: args.host.clone(),
35            identity_file: args.identity_file.clone(),
36            port: args.port,
37            read_only: args.read_only,
38        };
39        profile.validate()?;
40        if let Some(path) = &profile.identity_file {
41            let expanded = if let Ok(rest) = path.strip_prefix("~") {
42                dirs::home_dir()
43                    .ok_or_else(|| failure("Cannot locate home directory"))?
44                    .join(rest)
45            } else {
46                path.clone()
47            };
48            let absolute = expanded.canonicalize().map_err(|e| {
49                failure(format!(
50                    "Cannot access identity file {}: {e}",
51                    path.display()
52                ))
53            })?;
54            if !absolute.is_file() {
55                return Err(failure("Identity file must be a file"));
56            }
57            profile.identity_file = Some(absolute);
58        }
59        Ok(profile)
60    }
61    pub fn validate(&self) -> Result<(), Error> {
62        match self.backend {
63            Backend::Desktop
64                if self.host.is_some() || self.identity_file.is_some() || self.port.is_some() =>
65            {
66                return Err(failure(
67                    "--host, --identity-file and --port require --backend ssh",
68                ));
69            }
70            Backend::Ssh => {
71                let host = self.host.as_deref().ok_or_else(|| {
72                    failure("SSH profiles require --host HOST (an SSH alias or user@host)")
73                })?;
74                if host.is_empty()
75                    || host.len() > 255
76                    || host.starts_with('-')
77                    || host.starts_with('@')
78                    || host.ends_with('@')
79                    || host.matches('@').count() > 1
80                    || !host
81                        .bytes()
82                        .all(|c| c.is_ascii_alphanumeric() || b"@._-:[]".contains(&c))
83                {
84                    return Err(failure(
85                        "Invalid SSH destination; use a host alias or user@host without whitespace or shell syntax",
86                    ));
87                }
88                if self.port == Some(0) {
89                    return Err(failure("SSH port must be between 1 and 65535"));
90                }
91            }
92            _ => (),
93        }
94        Ok(())
95    }
96    pub fn target(&self) -> &str {
97        self.host.as_deref().unwrap_or("local Windows desktop")
98    }
99}
100#[derive(Debug, Serialize, Deserialize)]
101#[serde(default, deny_unknown_fields)]
102pub struct Config {
103    pub version: u32,
104    pub active_profile: Option<String>,
105    pub profiles: BTreeMap<String, Profile>,
106}
107impl Default for Config {
108    fn default() -> Self {
109        Self {
110            version: 1,
111            active_profile: None,
112            profiles: BTreeMap::new(),
113        }
114    }
115}
116fn failure(message: impl Into<String>) -> Error {
117    Error::new("config_error", message)
118}
119pub fn profile_name(name: &str) -> Result<String, String> {
120    if name.is_empty()
121        || name.len() > 64
122        || !name
123            .bytes()
124            .all(|c| c.is_ascii_alphanumeric() || b"_-.".contains(&c))
125    {
126        Err(
127            "profile names must contain 1–64 ASCII letters, digits, dots, underscores or hyphens"
128                .into(),
129        )
130    } else {
131        Ok(name.into())
132    }
133}
134pub fn path() -> Result<PathBuf, Error> {
135    if let Some(path) = std::env::var_os("ONENOTE_CONFIG") {
136        if path.is_empty() {
137            return Err(failure("ONENOTE_CONFIG must not be empty"));
138        }
139        return Ok(PathBuf::from(path));
140    }
141    let base = std::env::var_os("XDG_CONFIG_HOME")
142        .filter(|s| !s.is_empty())
143        .map(PathBuf::from)
144        .or_else(|| {
145            if cfg!(windows) {
146                dirs::config_dir()
147            } else {
148                dirs::home_dir().map(|p| p.join(".config"))
149            }
150        });
151    Ok(base
152        .ok_or_else(|| failure("Cannot locate configuration directory; set ONENOTE_CONFIG"))?
153        .join("onenote/config.toml"))
154}
155pub struct Store {
156    pub path: PathBuf,
157}
158impl Store {
159    pub fn open() -> Result<Self, Error> {
160        Ok(Self { path: path()? })
161    }
162    pub fn read(&self) -> Result<Option<Config>, Error> {
163        let file = match File::open(&self.path) {
164            Ok(f) => f,
165            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
166            Err(e) => return Err(failure(format!("Cannot read {}: {e}", self.path.display()))),
167        };
168        let mut contents = String::new();
169        file.take(1024 * 1024 + 1)
170            .read_to_string(&mut contents)
171            .map_err(|e| failure(e.to_string()))?;
172        if contents.len() > 1024 * 1024 {
173            return Err(failure("Configuration exceeds 1 MiB"));
174        }
175        let config: Config = toml::from_str(&contents).map_err(|e| {
176            failure(format!(
177                "Invalid configuration in {}: {e}",
178                self.path.display()
179            ))
180        })?;
181        if config.version != 1 {
182            return Err(failure(
183                "Unsupported configuration version; expected version = 1",
184            ));
185        }
186        for (name, profile) in &config.profiles {
187            profile_name(name).map_err(failure)?;
188            profile.validate()?;
189        }
190        if let Some(name) = &config.active_profile
191            && !config.profiles.contains_key(name)
192        {
193            return Err(failure(format!(
194                "Active profile '{name}' does not exist; repair {}",
195                self.path.display()
196            )));
197        }
198        Ok(Some(config))
199    }
200    pub fn resolve(&self, selected: Option<&str>) -> Result<(Option<String>, Profile), Error> {
201        let config = self.read()?;
202        let name = selected.or_else(|| config.as_ref().and_then(|c| c.active_profile.as_deref()));
203        if let Some(name) = name {
204            let profile = config
205                .as_ref()
206                .and_then(|c| c.profiles.get(name))
207                .ok_or_else(|| {
208                    failure(format!(
209                        "Profile '{name}' does not exist; run `onenote init --profile {name}`"
210                    ))
211                })?;
212            return Ok((Some(name.into()), profile.clone()));
213        }
214        if config.is_some() {
215            return Err(failure(
216                "No active profile; use `onenote profile use NAME` or `onenote init`",
217            ));
218        }
219        Ok((None, Profile::default()))
220    }
221    pub fn update(
222        &self,
223        change: impl FnOnce(&mut Config) -> Result<(), Error>,
224    ) -> Result<(), Error> {
225        let parent = self
226            .path
227            .parent()
228            .filter(|p| !p.as_os_str().is_empty())
229            .unwrap_or(std::path::Path::new("."));
230        fs::create_dir_all(parent)?;
231        let mut options = OpenOptions::new();
232        options.read(true).write(true).create(true).truncate(false);
233        #[cfg(unix)]
234        {
235            use std::os::unix::fs::OpenOptionsExt;
236            options.mode(0o600);
237        }
238        let lock = options.open(self.path.with_extension("toml.lock"))?;
239        lock.try_lock().map_err(|_| {
240            failure("Configuration is being updated by another process; retry shortly")
241        })?;
242        let mut config = self.read()?.unwrap_or_default();
243        change(&mut config)?;
244        let mut file = tempfile::NamedTempFile::new_in(parent)?;
245        #[cfg(unix)]
246        {
247            use std::os::unix::fs::PermissionsExt;
248            file.as_file()
249                .set_permissions(fs::Permissions::from_mode(0o600))?;
250        }
251        file.write_all(
252            toml::to_string_pretty(&config)
253                .map_err(|e| failure(e.to_string()))?
254                .as_bytes(),
255        )?;
256        file.as_file().sync_all()?;
257        file.persist(&self.path)
258            .map_err(|e| failure(format!("Cannot save {}: {e}", self.path.display())))?;
259        Ok(())
260    }
261    pub fn ensure_available(&self, name: &str, force: bool) -> Result<(), Error> {
262        if !force && self.read()?.is_some_and(|c| c.profiles.contains_key(name)) {
263            return Err(failure(format!(
264                "Profile '{name}' already exists; use --force to replace its settings"
265            )));
266        }
267        Ok(())
268    }
269    pub fn save(&self, name: &str, profile: Profile, force: bool) -> Result<(), Error> {
270        self.update(|config| {
271            if config.profiles.contains_key(name) && !force {
272                return Err(failure(format!(
273                    "Profile '{name}' already exists; use --force to replace its settings"
274                )));
275            }
276            config.profiles.insert(name.into(), profile);
277            config.active_profile = Some(name.into());
278            Ok(())
279        })
280    }
281}