1use std::cell::RefCell;
18use std::fs;
19use std::io::ErrorKind::NotFound;
20use std::io::Write as _;
21use std::path::Path;
22use std::path::PathBuf;
23
24use prost::Message as _;
25use rand::RngExt as _;
26use rand_chacha::ChaCha20Rng;
27use tempfile::NamedTempFile;
28use thiserror::Error;
29
30use crate::file_util::BadPathEncoding;
31use crate::file_util::IoResultExt as _;
32use crate::file_util::PathError;
33use crate::file_util::path_from_bytes;
34use crate::file_util::path_to_bytes;
35use crate::hex_util::encode_hex;
36use crate::protos::secure_config::ConfigMetadata;
37
38const CONFIG_FILE: &str = "config.toml";
39const METADATA_FILE: &str = "metadata.binpb";
40const CONFIG_ID_BYTES: usize = 10;
41#[cfg(not(unix))]
42const CONTENT_PREFIX: &str = r###"# DO NOT EDIT.
43# This file is for old versions of jj.
44# It will be used for jj >= v0.37.
45# Use `jj config path` or `jj config edit` to find and edit the new file
46
47"###;
48const CONFIG_NOT_FOUND: &str = r###"Per-repo config not found. Generating an empty one.
49Per-repo config is stored in the same directory as your user config for security reasons.
50If you work across multiple computers, you may want to keep your user config directory in sync."###;
51
52#[derive(Clone, Debug)]
54pub struct SecureConfig {
55 repo_dir: PathBuf,
57 config_id_name: &'static str,
59 legacy_config_name: &'static str,
61 cache: RefCell<Option<(Option<PathBuf>, ConfigMetadata)>>,
63}
64
65#[derive(Error, Debug)]
67pub enum SecureConfigError {
68 #[error(transparent)]
70 PathError(#[from] PathError),
71
72 #[error(transparent)]
74 DecodeError(#[from] prost::DecodeError),
75
76 #[error(transparent)]
78 BadPathEncoding(#[from] BadPathEncoding),
79
80 #[error("Found an invalid config ID")]
82 BadConfigIdError,
83}
84
85#[derive(Clone, Debug, Default)]
89pub struct LoadedSecureConfig {
90 pub config_file: Option<PathBuf>,
93 pub metadata: ConfigMetadata,
95 pub warnings: Vec<String>,
97}
98
99fn atomic_write(path: &Path, content: &[u8]) -> Result<(), SecureConfigError> {
100 let d = path.parent().unwrap();
101 let mut temp_file = NamedTempFile::new_in(d).context(d)?;
102 temp_file.write_all(content).context(temp_file.path())?;
103 temp_file.persist(path).map_err(|e| PathError {
104 path: path.to_path_buf(),
105 source: e.error,
106 })?;
107 Ok(())
108}
109
110fn generate_config_id(rng: &mut ChaCha20Rng) -> String {
111 encode_hex(&rng.random::<[u8; CONFIG_ID_BYTES]>())
112}
113
114fn update_metadata(config_dir: &Path, metadata: &ConfigMetadata) -> Result<(), SecureConfigError> {
115 let metadata_path = config_dir.join(METADATA_FILE);
116 atomic_write(&metadata_path, &metadata.encode_to_vec())?;
117 Ok(())
118}
119
120impl SecureConfig {
121 fn new(
123 repo_dir: PathBuf,
124 config_id_name: &'static str,
125 legacy_config_name: &'static str,
126 ) -> Self {
127 Self {
128 repo_dir,
129 config_id_name,
130 legacy_config_name,
131 cache: RefCell::new(None),
132 }
133 }
134
135 pub fn new_repo(repo_dir: PathBuf) -> Self {
137 Self::new(repo_dir, "config-id", "config.toml")
138 }
139
140 pub fn new_workspace(workspace_dir: PathBuf) -> Self {
142 Self::new(
143 workspace_dir,
144 "workspace-config-id",
145 "workspace-config.toml",
146 )
147 }
148
149 fn generate_config(
150 &self,
151 root_config_dir: &Path,
152 config_id: &str,
153 content: Option<&[u8]>,
154 metadata: &ConfigMetadata,
155 ) -> Result<PathBuf, SecureConfigError> {
156 let config_dir = root_config_dir.join(config_id);
157 let config_path = config_dir.join(CONFIG_FILE);
158 fs::create_dir_all(&config_dir).context(&config_dir)?;
159 update_metadata(&config_dir, metadata)?;
160 if let Some(content) = content {
161 fs::write(&config_path, content).context(&config_path)?;
162 }
163
164 atomic_write(
166 &self.repo_dir.join(self.config_id_name),
167 config_id.as_bytes(),
168 )?;
169 Ok(config_path)
170 }
171
172 fn generate_initial_config(
173 &self,
174 root_config_dir: &Path,
175 config_id: &str,
176 ) -> Result<(PathBuf, ConfigMetadata), SecureConfigError> {
177 let metadata = ConfigMetadata {
178 path: path_to_bytes(&self.repo_dir).ok().map(|b| b.to_vec()),
179 };
180 let path = self.generate_config(root_config_dir, config_id, None, &metadata)?;
181 Ok((path, metadata))
182 }
183
184 fn handle_metadata_path(
188 &self,
189 rng: &mut ChaCha20Rng,
190 root_config_dir: &Path,
191 config_dir: PathBuf,
192 mut metadata: ConfigMetadata,
193 ) -> Result<LoadedSecureConfig, SecureConfigError> {
194 let encoded = path_to_bytes(&self.repo_dir).ok();
195 let got = metadata.path.as_deref().map(path_from_bytes).transpose()?;
196
197 if got == encoded.is_some().then_some(self.repo_dir.as_path()) {
198 return Ok(LoadedSecureConfig {
199 config_file: Some(config_dir.join(CONFIG_FILE)),
200 metadata,
201 warnings: vec![],
202 });
203 }
204 let got = match got {
205 Some(d) if d.is_dir() => d.to_path_buf(),
206 _ => {
207 metadata.path = encoded.map(|b| b.to_vec());
209 update_metadata(&config_dir, &metadata)?;
210 return Ok(LoadedSecureConfig {
211 config_file: Some(config_dir.join(CONFIG_FILE)),
212 metadata,
213 warnings: vec![],
214 });
215 }
216 };
217 if let Ok(tmp) = NamedTempFile::new_in(&self.repo_dir)
222 && !got.join(tmp.path().file_name().unwrap()).exists()
223 {
224 let old_config_path = config_dir.join(CONFIG_FILE);
228 metadata.path = encoded.map(|b| b.to_vec());
229 let old_config_content = fs::read(&old_config_path).context(&old_config_path)?;
230 let config_path = self.generate_config(
231 root_config_dir,
232 &generate_config_id(rng),
233 Some(&old_config_content),
234 &metadata,
235 )?;
236 return Ok(LoadedSecureConfig {
237 config_file: Some(config_path.clone()),
238 metadata,
239 warnings: vec![format!(
240 "Your repo appears to have been copied from {} to {}. The corresponding repo \
241 config file has also been copied.",
242 got.display(),
243 &self.repo_dir.display()
244 )],
245 });
246 }
247 Ok(LoadedSecureConfig {
248 config_file: Some(config_dir.join(CONFIG_FILE)),
249 metadata,
250 warnings: vec![],
251 })
252 }
253
254 #[cfg(unix)]
255 fn update_legacy_config_file(
256 &self,
257 new_config: &Path,
258 _content: &[u8],
259 ) -> Result<(), SecureConfigError> {
260 let legacy_config = self.repo_dir.join(self.legacy_config_name);
261 fs::remove_file(&legacy_config).context(&legacy_config)?;
263 std::os::unix::fs::symlink(new_config, &legacy_config).context(&legacy_config)?;
264 Ok(())
265 }
266
267 #[cfg(not(unix))]
268 fn update_legacy_config_file(
269 &self,
270 _new_config: &Path,
271 content: &[u8],
272 ) -> Result<(), SecureConfigError> {
273 let legacy_config = self.repo_dir.join(self.legacy_config_name);
274 let mut new_content = CONTENT_PREFIX.as_bytes().to_vec();
281 new_content.extend_from_slice(content);
282 fs::write(&legacy_config, new_content).context(&legacy_config)?;
283 Ok(())
284 }
285
286 fn maybe_migrate_legacy_config(
288 &self,
289 rng: &mut ChaCha20Rng,
290 root_config_dir: &Path,
291 ) -> Result<LoadedSecureConfig, SecureConfigError> {
292 let legacy_config = self.repo_dir.join(self.legacy_config_name);
295 let config = match fs::read(&legacy_config).context(&legacy_config) {
296 Ok(config_content) => config_content,
297 Err(e) if e.source.kind() == NotFound => return Ok(Default::default()),
299 Err(e) => return Err(e.into()),
300 };
301 let metadata = ConfigMetadata {
302 path: path_to_bytes(&self.repo_dir).ok().map(|b| b.to_vec()),
303 };
304 let config_file = self.generate_config(
305 root_config_dir,
306 &generate_config_id(rng),
307 Some(&config),
308 &metadata,
309 )?;
310 self.update_legacy_config_file(&config_file, &config)?;
311 Ok(LoadedSecureConfig {
312 warnings: vec![format!(
313 "Your config file has been migrated from {} to {}. You can edit the new file with \
314 `jj config edit`",
315 legacy_config.display(),
316 config_file.display(),
317 )],
318 config_file: Some(config_file),
319 metadata,
320 })
321 }
322
323 pub fn maybe_load_config(
326 &self,
327 rng: &mut ChaCha20Rng,
328 root_config_dir: &Path,
329 ) -> Result<LoadedSecureConfig, SecureConfigError> {
330 if let Some(cache) = self.cache.borrow().as_ref() {
331 return Ok(LoadedSecureConfig {
332 config_file: cache.0.clone(),
333 metadata: cache.1.clone(),
334 warnings: vec![],
335 });
336 }
337 let config_id_path = self.repo_dir.join(self.config_id_name);
338 let loaded = match fs::read_to_string(&config_id_path).context(&config_id_path) {
339 Ok(config_id) => {
340 if config_id.len() != CONFIG_ID_BYTES * 2
341 || !config_id.chars().all(|c| c.is_ascii_hexdigit())
342 {
343 return Err(SecureConfigError::BadConfigIdError);
344 }
345 let config_dir = root_config_dir.join(&config_id);
346 let metadata_path = config_dir.join(METADATA_FILE);
347 match fs::read(&metadata_path).context(&metadata_path) {
348 Ok(buf) => self.handle_metadata_path(
349 rng,
350 root_config_dir,
351 config_dir,
352 ConfigMetadata::decode(buf.as_slice())?,
353 )?,
354 Err(e) if e.source.kind() == NotFound => {
355 let (path, metadata) =
356 self.generate_initial_config(root_config_dir, &config_id)?;
357 LoadedSecureConfig {
358 config_file: Some(path),
359 metadata,
360 warnings: vec![CONFIG_NOT_FOUND.to_string()],
361 }
362 }
363 Err(e) => return Err(e.into()),
364 }
365 }
366 Err(e) if e.source.kind() == NotFound => {
367 self.maybe_migrate_legacy_config(rng, root_config_dir)?
368 }
369 Err(e) => return Err(SecureConfigError::PathError(e)),
370 };
371 *self.cache.borrow_mut() = Some((loaded.config_file.clone(), loaded.metadata.clone()));
372 Ok(loaded)
373 }
374
375 pub fn load_config(
378 &self,
379 rng: &mut ChaCha20Rng,
380 root_config_dir: &Path,
381 ) -> Result<LoadedSecureConfig, SecureConfigError> {
382 let mut loaded = self.maybe_load_config(rng, root_config_dir)?;
383 if loaded.config_file.is_none() {
384 let (path, metadata) =
385 self.generate_initial_config(root_config_dir, &generate_config_id(rng))?;
386 *self.cache.borrow_mut() = Some((Some(path.clone()), metadata.clone()));
387 loaded.config_file = Some(path);
388 loaded.metadata = metadata;
389 }
390 Ok(loaded)
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use std::ffi::OsStr;
397
398 use rand::SeedableRng as _;
399 use tempfile::TempDir;
400
401 use super::*;
402 use crate::tests::TestResult;
403
404 struct TestEnv {
405 _td: TempDir,
406 rng: ChaCha20Rng,
407 config: SecureConfig,
408 repo_dir: PathBuf,
409 config_dir: PathBuf,
410 }
411
412 impl TestEnv {
413 fn new() -> Self {
414 let td = crate::tests::new_temp_dir();
415 let repo_dir = td.path().join("repo");
416 fs::create_dir(&repo_dir).unwrap();
417 let config_dir = td.path().join("config");
418 fs::create_dir(&config_dir).unwrap();
419 Self {
420 _td: td,
421 rng: ChaCha20Rng::seed_from_u64(0),
422 config: SecureConfig::new(repo_dir.clone(), "config-id", "legacy-config.toml"),
423 repo_dir,
424 config_dir,
425 }
426 }
427
428 fn secure_config_for_dir(&self, d: PathBuf) -> SecureConfig {
429 SecureConfig::new(d, "config-id", "legacy-config.toml")
430 }
431 }
432
433 #[test]
434 fn test_no_initial_config() -> TestResult {
435 let mut env = TestEnv::new();
436
437 let loaded = env
439 .config
440 .maybe_load_config(&mut env.rng, &env.config_dir)?;
441 assert_eq!(loaded.config_file, None);
442 assert_eq!(loaded.metadata, Default::default());
443 assert!(loaded.warnings.is_empty());
444 assert!(env.config.cache.borrow().is_some());
446
447 let loaded = env.config.load_config(&mut env.rng, &env.config_dir)?;
449 let path = loaded.config_file.unwrap();
450 let components: Vec<_> = path.components().rev().collect();
451 assert_eq!(
452 components[0],
453 std::path::Component::Normal(OsStr::new("config.toml"))
454 );
455 assert_eq!(
456 components[2],
457 std::path::Component::Normal(OsStr::new("config"))
458 );
459 assert!(!loaded.metadata.path.as_deref().unwrap().is_empty());
460 assert!(loaded.warnings.is_empty());
461
462 assert!(env.config.cache.borrow().is_some());
465 *env.config.cache.borrow_mut() = None;
466 let loaded2 = env.config.load_config(&mut env.rng, &env.config_dir)?;
467 assert_eq!(loaded2.config_file.unwrap(), path);
468 assert_eq!(loaded2.metadata, loaded.metadata);
469 assert!(loaded2.warnings.is_empty());
470 Ok(())
471 }
472
473 #[test]
474 fn test_migrate_legacy_config() -> TestResult {
475 let mut env = TestEnv::new();
476
477 let legacy_config = env.repo_dir.join("legacy-config.toml");
478 fs::write(&legacy_config, "config")?;
479 let loaded = env
480 .config
481 .maybe_load_config(&mut env.rng, &env.config_dir)?;
482 assert!(loaded.config_file.is_some());
483 assert!(!loaded.metadata.path.unwrap().is_empty());
484 let config_contents = fs::read_to_string(loaded.config_file.as_deref().unwrap())?;
485 assert_eq!(config_contents, "config");
486 assert!(!loaded.warnings.is_empty());
487
488 if cfg!(unix) {
490 fs::write(loaded.config_file.as_deref().unwrap(), "new")?;
491 let legacy_contents = fs::read_to_string(&legacy_config)?;
492 assert_eq!(legacy_contents, "new");
493 }
494 Ok(())
495 }
496
497 #[test]
498 fn test_repo_moved() -> TestResult {
499 let mut env = TestEnv::new();
500 let loaded = env.config.load_config(&mut env.rng, &env.config_dir)?;
501 let path = loaded.config_file.unwrap();
502
503 let dest = env.repo_dir.parent().unwrap().join("moved");
504 fs::rename(&env.repo_dir, &dest)?;
505 let config = env.secure_config_for_dir(dest);
506 let loaded2 = config.load_config(&mut env.rng, &env.config_dir)?;
507 assert_eq!(loaded2.config_file.unwrap(), path);
508 assert_ne!(loaded.metadata.path, loaded2.metadata.path);
509 assert!(loaded2.warnings.is_empty());
510 Ok(())
511 }
512
513 #[test]
514 fn test_repo_copied() -> TestResult {
515 let mut env = TestEnv::new();
516 let loaded = env.config.load_config(&mut env.rng, &env.config_dir)?;
517 let path = loaded.config_file.unwrap();
518 fs::write(&path, "config")?;
519
520 let dest = env.repo_dir.parent().unwrap().join("copied");
521 fs::create_dir(&dest)?;
522 fs::copy(env.repo_dir.join("config-id"), dest.join("config-id"))?;
523 let config = env.secure_config_for_dir(dest);
524 let loaded2 = config.load_config(&mut env.rng, &env.config_dir)?;
525 let path2 = loaded2.config_file.unwrap();
526 assert_ne!(path, path2);
527 let path2_contents = fs::read_to_string(path2)?;
528 assert_eq!(path2_contents, "config");
529 assert_ne!(loaded.metadata.path, loaded2.metadata.path);
530 assert!(!loaded2.warnings.is_empty());
532 Ok(())
533 }
534
535 #[cfg(unix)]
538 #[test]
539 fn test_repo_aliased() -> TestResult {
540 let mut env = TestEnv::new();
541 let loaded = env.config.load_config(&mut env.rng, &env.config_dir)?;
542 let path = loaded.config_file.unwrap();
543
544 let dest = env.repo_dir.parent().unwrap().join("copied");
545 std::os::unix::fs::symlink(&env.repo_dir, &dest)?;
546 let config = env.secure_config_for_dir(dest);
547 let loaded2 = config.load_config(&mut env.rng, &env.config_dir)?;
548 assert_eq!(loaded2.config_file.unwrap(), path);
549 assert_eq!(loaded.metadata.path, loaded2.metadata.path);
550 assert!(loaded2.warnings.is_empty());
551 Ok(())
552 }
553
554 #[test]
555 fn test_missing_config() -> TestResult {
556 let mut env = TestEnv::new();
557 let loaded = env.config.load_config(&mut env.rng, &env.config_dir)?;
558 let path = loaded.config_file.unwrap();
559
560 fs::remove_dir_all(path.parent().unwrap())?;
561 *env.config.cache.borrow_mut() = None;
562
563 let loaded2 = env.config.load_config(&mut env.rng, &env.config_dir)?;
564 assert_eq!(loaded2.config_file.unwrap(), path);
565 assert_eq!(loaded.metadata.path, loaded2.metadata.path);
566 assert!(path.parent().unwrap().is_dir());
568 assert!(!loaded2.warnings.is_empty());
569 Ok(())
570 }
571}