Skip to main content

jj_lib/
secure_config.rs

1// Copyright 2025 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! A mechanism to access config files for a repo securely.
16
17use 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/// A mechanism to access config files for a repo securely.
53#[derive(Clone, Debug)]
54pub struct SecureConfig {
55    /// Technically this is either a repo or a workspace.
56    repo_dir: PathBuf,
57    /// The name of the config id file.
58    config_id_name: &'static str,
59    /// The name of the legacy config file.
60    legacy_config_name: &'static str,
61    /// A cache of the output \[maybe_\]load_config
62    cache: RefCell<Option<(Option<PathBuf>, ConfigMetadata)>>,
63}
64
65/// An error when attempting to load config from disk.
66#[derive(Error, Debug)]
67pub enum SecureConfigError {
68    /// Failed to read / write to the specified path
69    #[error(transparent)]
70    PathError(#[from] PathError),
71
72    /// Failed to decode the user configuration file.
73    #[error(transparent)]
74    DecodeError(#[from] prost::DecodeError),
75
76    /// The path failed to decode to bytes.
77    #[error(transparent)]
78    BadPathEncoding(#[from] BadPathEncoding),
79
80    /// The config ID isn't CONFIG_ID_BYTES * 2 hex chars.
81    #[error("Found an invalid config ID")]
82    BadConfigIdError,
83}
84
85/// The path to the config file for a secure config.
86/// Also contains metadata about the repo and info to be displayed to the
87/// user.
88#[derive(Clone, Debug, Default)]
89pub struct LoadedSecureConfig {
90    /// The path to the config file.
91    /// Can be None if the config-ID is not generated.
92    pub config_file: Option<PathBuf>,
93    /// The metadata for the config.
94    pub metadata: ConfigMetadata,
95    /// Any warnings that we want to be reported to the user.
96    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
120/// Reads and decodes the `metadata.binpb` file in the given per-repo config
121/// directory.
122pub 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
128/// Returns the repo/workspace path stored in the metadata, if present.
129pub fn metadata_path(metadata: &ConfigMetadata) -> Result<Option<&Path>, BadPathEncoding> {
130    metadata.path.as_deref().map(path_from_bytes).transpose()
131}
132
133/// Removes the well-known files inside a per-repo config directory
134/// (`config.toml` and `metadata.binpb`) and then the directory itself.
135///
136/// The directory is removed non-recursively, so this errors out if any other
137/// file or subdirectory is present — better to leave a user-placed file in
138/// place than to silently delete it as part of a recursive `rm`.
139pub 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    /// Creates a secure config.
152    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    /// Creates a secure config for a repo. Takes the .jj/repo directory.
166    pub fn new_repo(repo_dir: PathBuf) -> Self {
167        Self::new(repo_dir, "config-id", "config.toml")
168    }
169
170    /// Creates a secure config for a workspace. Takes the .jj directory.
171    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        // Write the config ID atomically. A half-formed config ID would be very bad.
195        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    /// Validates that the metadata path matches the repo path.
215    /// If there's a mismatch, takes appropriate action.
216    /// Returns the updated config dir and metadata.
217    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                // The old repo does not exist. Assume the user moved it.
238                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        // We attempt to create a temporary file in the new repo.
248        // If it fails, we have readonly access to a repo, so we do nothing.
249        // If we write to the new repo and it shows up in the old one,
250        // we can skip this step, since it's not a copy.
251        if let Ok(tmp) = NamedTempFile::new_in(&self.repo_dir)
252            && !got.join(tmp.path().file_name().unwrap()).exists()
253        {
254            // We now assume the repo was copied. Since the repo was copied,
255            // the config should be copied too, rather than sharing the
256            // config with what it copied from.
257            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        // Make old versions and new versions of jj share the same config file.
292        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        // I considered making this readonly, but that would prevent you from
305        // updating the config with old versions of jj.
306        // In the future, we consider something a little more robust, where as
307        // the non-legacy config changes, we propagate that to the legacy config.
308        // However, it seems a little overkill, considering it only affects windows
309        // users who use multiple versions of jj at once, and only for a year.
310        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    /// Migrates the legacy config, if it exists.
317    fn maybe_migrate_legacy_config(
318        &self,
319        rng: &mut ChaCha20Rng,
320        root_config_dir: &Path,
321    ) -> Result<LoadedSecureConfig, SecureConfigError> {
322        // TODO: This function should be updated in jj 0.49 to no longer
323        // automatically migrate repos, but instead print out a warning.
324        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            // No legacy config files found.
328            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    /// Determines the path to the config, and any metadata associated with it.
354    /// If no config exists, the path will be None.
355    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    /// Determines the path to the config, and any metadata associated with it.
402    /// If no config exists, an empty config file will be generated.
403    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        // We shouldn't generate the config.
464        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        // The cache entry should be filled.
471        assert!(env.config.cache.borrow().is_some());
472
473        // load_config should generate the config if it previously didn't exist.
474        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        // load_config should leave it untouched if it did exist.
489        // Empty the cache to ensure the function is actually being tested
490        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        // On unix, it should be a symlink.
515        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        // We should get a warning about the repo having been copied.
557        assert!(!loaded2.warnings.is_empty());
558        Ok(())
559    }
560
561    // This feature works on windows as well, it just isn't easy to replicate with a
562    // test.
563    #[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        // It should have recreated the directory.
593        assert!(path.parent().unwrap().is_dir());
594        assert!(!loaded2.warnings.is_empty());
595        Ok(())
596    }
597}