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
120pub fn read_metadata(config_dir: &Path) -> Result<ConfigMetadata, SecureConfigError> {
123 let metadata_path = config_dir.join(METADATA_FILE);
124 let bytes = fs::read(&metadata_path).context(&metadata_path)?;
125 Ok(ConfigMetadata::decode(bytes.as_slice())?)
126}
127
128pub fn metadata_path(metadata: &ConfigMetadata) -> Result<Option<&Path>, BadPathEncoding> {
130 metadata.path.as_deref().map(path_from_bytes).transpose()
131}
132
133pub fn remove_repo_config_dir(config_dir: &Path) -> std::io::Result<()> {
140 for path in [config_dir.join(CONFIG_FILE), config_dir.join(METADATA_FILE)] {
141 match fs::remove_file(&path) {
142 Ok(()) => {}
143 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
144 Err(err) => return Err(err),
145 }
146 }
147 fs::remove_dir(config_dir)
148}
149
150impl SecureConfig {
151 fn new(
153 repo_dir: PathBuf,
154 config_id_name: &'static str,
155 legacy_config_name: &'static str,
156 ) -> Self {
157 Self {
158 repo_dir,
159 config_id_name,
160 legacy_config_name,
161 cache: RefCell::new(None),
162 }
163 }
164
165 pub fn new_repo(repo_dir: PathBuf) -> Self {
167 Self::new(repo_dir, "config-id", "config.toml")
168 }
169
170 pub fn new_workspace(workspace_dir: PathBuf) -> Self {
172 Self::new(
173 workspace_dir,
174 "workspace-config-id",
175 "workspace-config.toml",
176 )
177 }
178
179 fn generate_config(
180 &self,
181 root_config_dir: &Path,
182 config_id: &str,
183 content: Option<&[u8]>,
184 metadata: &ConfigMetadata,
185 ) -> Result<PathBuf, SecureConfigError> {
186 let config_dir = root_config_dir.join(config_id);
187 let config_path = config_dir.join(CONFIG_FILE);
188 fs::create_dir_all(&config_dir).context(&config_dir)?;
189 update_metadata(&config_dir, metadata)?;
190 if let Some(content) = content {
191 fs::write(&config_path, content).context(&config_path)?;
192 }
193
194 atomic_write(
196 &self.repo_dir.join(self.config_id_name),
197 config_id.as_bytes(),
198 )?;
199 Ok(config_path)
200 }
201
202 fn generate_initial_config(
203 &self,
204 root_config_dir: &Path,
205 config_id: &str,
206 ) -> Result<(PathBuf, ConfigMetadata), SecureConfigError> {
207 let metadata = ConfigMetadata {
208 path: path_to_bytes(&self.repo_dir).ok().map(|b| b.to_vec()),
209 };
210 let path = self.generate_config(root_config_dir, config_id, None, &metadata)?;
211 Ok((path, metadata))
212 }
213
214 fn handle_metadata_path(
218 &self,
219 rng: &mut ChaCha20Rng,
220 root_config_dir: &Path,
221 config_dir: PathBuf,
222 mut metadata: ConfigMetadata,
223 ) -> Result<LoadedSecureConfig, SecureConfigError> {
224 let encoded = path_to_bytes(&self.repo_dir).ok();
225 let got = metadata_path(&metadata)?;
226
227 if got == encoded.is_some().then_some(self.repo_dir.as_path()) {
228 return Ok(LoadedSecureConfig {
229 config_file: Some(config_dir.join(CONFIG_FILE)),
230 metadata,
231 warnings: vec![],
232 });
233 }
234 let got = match got {
235 Some(d) if d.is_dir() => d.to_path_buf(),
236 _ => {
237 metadata.path = encoded.map(|b| b.to_vec());
239 update_metadata(&config_dir, &metadata)?;
240 return Ok(LoadedSecureConfig {
241 config_file: Some(config_dir.join(CONFIG_FILE)),
242 metadata,
243 warnings: vec![],
244 });
245 }
246 };
247 if let Ok(tmp) = NamedTempFile::new_in(&self.repo_dir)
252 && !got.join(tmp.path().file_name().unwrap()).exists()
253 {
254 let old_config_path = config_dir.join(CONFIG_FILE);
258 metadata.path = encoded.map(|b| b.to_vec());
259 let old_config_content = fs::read(&old_config_path).context(&old_config_path)?;
260 let config_path = self.generate_config(
261 root_config_dir,
262 &generate_config_id(rng),
263 Some(&old_config_content),
264 &metadata,
265 )?;
266 return Ok(LoadedSecureConfig {
267 config_file: Some(config_path.clone()),
268 metadata,
269 warnings: vec![format!(
270 "Your repo appears to have been copied from {} to {}. The corresponding repo \
271 config file has also been copied.",
272 got.display(),
273 &self.repo_dir.display()
274 )],
275 });
276 }
277 Ok(LoadedSecureConfig {
278 config_file: Some(config_dir.join(CONFIG_FILE)),
279 metadata,
280 warnings: vec![],
281 })
282 }
283
284 #[cfg(unix)]
285 fn update_legacy_config_file(
286 &self,
287 new_config: &Path,
288 _content: &[u8],
289 ) -> Result<(), SecureConfigError> {
290 let legacy_config = self.repo_dir.join(self.legacy_config_name);
291 fs::remove_file(&legacy_config).context(&legacy_config)?;
293 std::os::unix::fs::symlink(new_config, &legacy_config).context(&legacy_config)?;
294 Ok(())
295 }
296
297 #[cfg(not(unix))]
298 fn update_legacy_config_file(
299 &self,
300 _new_config: &Path,
301 content: &[u8],
302 ) -> Result<(), SecureConfigError> {
303 let legacy_config = self.repo_dir.join(self.legacy_config_name);
304 let mut new_content = CONTENT_PREFIX.as_bytes().to_vec();
311 new_content.extend_from_slice(content);
312 fs::write(&legacy_config, new_content).context(&legacy_config)?;
313 Ok(())
314 }
315
316 fn maybe_migrate_legacy_config(
318 &self,
319 rng: &mut ChaCha20Rng,
320 root_config_dir: &Path,
321 ) -> Result<LoadedSecureConfig, SecureConfigError> {
322 let legacy_config = self.repo_dir.join(self.legacy_config_name);
325 let config = match fs::read(&legacy_config).context(&legacy_config) {
326 Ok(config_content) => config_content,
327 Err(e) if e.source.kind() == NotFound => return Ok(Default::default()),
329 Err(e) => return Err(e.into()),
330 };
331 let metadata = ConfigMetadata {
332 path: path_to_bytes(&self.repo_dir).ok().map(|b| b.to_vec()),
333 };
334 let config_file = self.generate_config(
335 root_config_dir,
336 &generate_config_id(rng),
337 Some(&config),
338 &metadata,
339 )?;
340 self.update_legacy_config_file(&config_file, &config)?;
341 Ok(LoadedSecureConfig {
342 warnings: vec![format!(
343 "Your config file has been migrated from {} to {}. You can edit the new file with \
344 `jj config edit`",
345 legacy_config.display(),
346 config_file.display(),
347 )],
348 config_file: Some(config_file),
349 metadata,
350 })
351 }
352
353 pub fn maybe_load_config(
356 &self,
357 rng: &mut ChaCha20Rng,
358 root_config_dir: &Path,
359 ) -> Result<LoadedSecureConfig, SecureConfigError> {
360 if let Some(cache) = self.cache.borrow().as_ref() {
361 return Ok(LoadedSecureConfig {
362 config_file: cache.0.clone(),
363 metadata: cache.1.clone(),
364 warnings: vec![],
365 });
366 }
367 let config_id_path = self.repo_dir.join(self.config_id_name);
368 let loaded = match fs::read_to_string(&config_id_path).context(&config_id_path) {
369 Ok(config_id) => {
370 if config_id.len() != CONFIG_ID_BYTES * 2
371 || !config_id.chars().all(|c| c.is_ascii_hexdigit())
372 {
373 return Err(SecureConfigError::BadConfigIdError);
374 }
375 let config_dir = root_config_dir.join(&config_id);
376 match read_metadata(&config_dir) {
377 Ok(metadata) => {
378 self.handle_metadata_path(rng, root_config_dir, config_dir, metadata)?
379 }
380 Err(SecureConfigError::PathError(e)) if e.source.kind() == NotFound => {
381 let (path, metadata) =
382 self.generate_initial_config(root_config_dir, &config_id)?;
383 LoadedSecureConfig {
384 config_file: Some(path),
385 metadata,
386 warnings: vec![CONFIG_NOT_FOUND.to_string()],
387 }
388 }
389 Err(e) => return Err(e),
390 }
391 }
392 Err(e) if e.source.kind() == NotFound => {
393 self.maybe_migrate_legacy_config(rng, root_config_dir)?
394 }
395 Err(e) => return Err(SecureConfigError::PathError(e)),
396 };
397 *self.cache.borrow_mut() = Some((loaded.config_file.clone(), loaded.metadata.clone()));
398 Ok(loaded)
399 }
400
401 pub fn load_config(
404 &self,
405 rng: &mut ChaCha20Rng,
406 root_config_dir: &Path,
407 ) -> Result<LoadedSecureConfig, SecureConfigError> {
408 let mut loaded = self.maybe_load_config(rng, root_config_dir)?;
409 if loaded.config_file.is_none() {
410 let (path, metadata) =
411 self.generate_initial_config(root_config_dir, &generate_config_id(rng))?;
412 *self.cache.borrow_mut() = Some((Some(path.clone()), metadata.clone()));
413 loaded.config_file = Some(path);
414 loaded.metadata = metadata;
415 }
416 Ok(loaded)
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use std::ffi::OsStr;
423
424 use rand::SeedableRng as _;
425 use tempfile::TempDir;
426
427 use super::*;
428 use crate::tests::TestResult;
429
430 struct TestEnv {
431 _td: TempDir,
432 rng: ChaCha20Rng,
433 config: SecureConfig,
434 repo_dir: PathBuf,
435 config_dir: PathBuf,
436 }
437
438 impl TestEnv {
439 fn new() -> Self {
440 let td = crate::tests::new_temp_dir();
441 let repo_dir = td.path().join("repo");
442 fs::create_dir(&repo_dir).unwrap();
443 let config_dir = td.path().join("config");
444 fs::create_dir(&config_dir).unwrap();
445 Self {
446 _td: td,
447 rng: ChaCha20Rng::seed_from_u64(0),
448 config: SecureConfig::new(repo_dir.clone(), "config-id", "legacy-config.toml"),
449 repo_dir,
450 config_dir,
451 }
452 }
453
454 fn secure_config_for_dir(&self, d: PathBuf) -> SecureConfig {
455 SecureConfig::new(d, "config-id", "legacy-config.toml")
456 }
457 }
458
459 #[test]
460 fn test_no_initial_config() -> TestResult {
461 let mut env = TestEnv::new();
462
463 let loaded = env
465 .config
466 .maybe_load_config(&mut env.rng, &env.config_dir)?;
467 assert_eq!(loaded.config_file, None);
468 assert_eq!(loaded.metadata, Default::default());
469 assert!(loaded.warnings.is_empty());
470 assert!(env.config.cache.borrow().is_some());
472
473 let loaded = env.config.load_config(&mut env.rng, &env.config_dir)?;
475 let path = loaded.config_file.unwrap();
476 let components: Vec<_> = path.components().rev().collect();
477 assert_eq!(
478 components[0],
479 std::path::Component::Normal(OsStr::new("config.toml"))
480 );
481 assert_eq!(
482 components[2],
483 std::path::Component::Normal(OsStr::new("config"))
484 );
485 assert!(!loaded.metadata.path.as_deref().unwrap().is_empty());
486 assert!(loaded.warnings.is_empty());
487
488 assert!(env.config.cache.borrow().is_some());
491 *env.config.cache.borrow_mut() = None;
492 let loaded2 = env.config.load_config(&mut env.rng, &env.config_dir)?;
493 assert_eq!(loaded2.config_file.unwrap(), path);
494 assert_eq!(loaded2.metadata, loaded.metadata);
495 assert!(loaded2.warnings.is_empty());
496 Ok(())
497 }
498
499 #[test]
500 fn test_migrate_legacy_config() -> TestResult {
501 let mut env = TestEnv::new();
502
503 let legacy_config = env.repo_dir.join("legacy-config.toml");
504 fs::write(&legacy_config, "config")?;
505 let loaded = env
506 .config
507 .maybe_load_config(&mut env.rng, &env.config_dir)?;
508 assert!(loaded.config_file.is_some());
509 assert!(!loaded.metadata.path.unwrap().is_empty());
510 let config_contents = fs::read_to_string(loaded.config_file.as_deref().unwrap())?;
511 assert_eq!(config_contents, "config");
512 assert!(!loaded.warnings.is_empty());
513
514 if cfg!(unix) {
516 fs::write(loaded.config_file.as_deref().unwrap(), "new")?;
517 let legacy_contents = fs::read_to_string(&legacy_config)?;
518 assert_eq!(legacy_contents, "new");
519 }
520 Ok(())
521 }
522
523 #[test]
524 fn test_repo_moved() -> TestResult {
525 let mut env = TestEnv::new();
526 let loaded = env.config.load_config(&mut env.rng, &env.config_dir)?;
527 let path = loaded.config_file.unwrap();
528
529 let dest = env.repo_dir.parent().unwrap().join("moved");
530 fs::rename(&env.repo_dir, &dest)?;
531 let config = env.secure_config_for_dir(dest);
532 let loaded2 = config.load_config(&mut env.rng, &env.config_dir)?;
533 assert_eq!(loaded2.config_file.unwrap(), path);
534 assert_ne!(loaded.metadata.path, loaded2.metadata.path);
535 assert!(loaded2.warnings.is_empty());
536 Ok(())
537 }
538
539 #[test]
540 fn test_repo_copied() -> TestResult {
541 let mut env = TestEnv::new();
542 let loaded = env.config.load_config(&mut env.rng, &env.config_dir)?;
543 let path = loaded.config_file.unwrap();
544 fs::write(&path, "config")?;
545
546 let dest = env.repo_dir.parent().unwrap().join("copied");
547 fs::create_dir(&dest)?;
548 fs::copy(env.repo_dir.join("config-id"), dest.join("config-id"))?;
549 let config = env.secure_config_for_dir(dest);
550 let loaded2 = config.load_config(&mut env.rng, &env.config_dir)?;
551 let path2 = loaded2.config_file.unwrap();
552 assert_ne!(path, path2);
553 let path2_contents = fs::read_to_string(path2)?;
554 assert_eq!(path2_contents, "config");
555 assert_ne!(loaded.metadata.path, loaded2.metadata.path);
556 assert!(!loaded2.warnings.is_empty());
558 Ok(())
559 }
560
561 #[cfg(unix)]
564 #[test]
565 fn test_repo_aliased() -> TestResult {
566 let mut env = TestEnv::new();
567 let loaded = env.config.load_config(&mut env.rng, &env.config_dir)?;
568 let path = loaded.config_file.unwrap();
569
570 let dest = env.repo_dir.parent().unwrap().join("copied");
571 std::os::unix::fs::symlink(&env.repo_dir, &dest)?;
572 let config = env.secure_config_for_dir(dest);
573 let loaded2 = config.load_config(&mut env.rng, &env.config_dir)?;
574 assert_eq!(loaded2.config_file.unwrap(), path);
575 assert_eq!(loaded.metadata.path, loaded2.metadata.path);
576 assert!(loaded2.warnings.is_empty());
577 Ok(())
578 }
579
580 #[test]
581 fn test_missing_config() -> TestResult {
582 let mut env = TestEnv::new();
583 let loaded = env.config.load_config(&mut env.rng, &env.config_dir)?;
584 let path = loaded.config_file.unwrap();
585
586 fs::remove_dir_all(path.parent().unwrap())?;
587 *env.config.cache.borrow_mut() = None;
588
589 let loaded2 = env.config.load_config(&mut env.rng, &env.config_dir)?;
590 assert_eq!(loaded2.config_file.unwrap(), path);
591 assert_eq!(loaded.metadata.path, loaded2.metadata.path);
592 assert!(path.parent().unwrap().is_dir());
594 assert!(!loaded2.warnings.is_empty());
595 Ok(())
596 }
597}