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}