Skip to content

Commit c0abea7

Browse files
committed
design: Add design doc for managed configuration
1 parent e698b52 commit c0abea7

File tree

2 files changed

+279
-0
lines changed

2 files changed

+279
-0
lines changed

docs/design/managed-config.md

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
# Repo-managed JJ configuration
2+
3+
Author: [Matt Stark](mailto:[email protected])
4+
5+
## Background
6+
7+
The design doc [Secure JJ Config](secure-config.md) introduces a mechanism,
8+
`metadata.binpb`, through which information about a repository / workspace can
9+
be stored. It also discusses how allowing an external user to have control over
10+
your config is a security risk.
11+
12+
## Overview
13+
14+
There is a need for a repository to impose requirements upon users. Examples of
15+
these things include, but are not limited to:
16+
17+
* Formatter configuration (eg.
18+
[chromium](https://source.chromium.org/chromium/chromium/src/+/main:tools/jj/config.toml;l=47-96;drc=080c978973f87ff2a1cfa514a13285baeaf3eedc))
19+
* Pre-upload checks (eg.
20+
[chromium](https://source.chromium.org/chromium/chromium/src/+/main:tools/jj/upload.py;drc=96f39fbbb720ca391d43cbb199a85af7d3309dd3))
21+
* Syncing scripts (eg.
22+
[chromium](https://source.chromium.org/chromium/chromium/src/+/main:tools/jj/sync.py;drc=6ff08dcdd1fdeb1654a4d3da81d8adaeae4bbbf7))
23+
* Custom aliases / revsets documented by some kind of “getting started with
24+
<project>” document
25+
26+
It should be fairly obvious that there is a strong benefit to doing so. However,
27+
controlling a user’s config is sufficient to get root access to their machine,
28+
so we require a mechanism more complex than just blindly loading the config
29+
file.
30+
31+
This is currently achieved by projects such as chromium by instructing the user
32+
to symlink `.jj/repo/config.toml` to a file in the repository. This has several
33+
drawbacks:
34+
35+
* It doesn’t work out-of-the-box. I need to manually symlink it
36+
* It has no security guarantees. If I update the file in the repo, the user has no
37+
opportunity to review it.
38+
* This prevents a user from having their own repo configuration on top of it.
39+
40+
## Objective
41+
42+
* Create a new layer of configuration between user configuration and repo
43+
configuration.
44+
* This configuration will be stored in version control and henceforth be
45+
referred to as “managed” configuration.
46+
* Implement it in a secure manner so that an attacker cannot take control over
47+
the managed config.
48+
49+
## Detailed Design
50+
51+
The managed configuration will be read from `$REPO/.config/jj/config.toml`. This
52+
is intentionally designed to be very similar to `$HOME/.config/jj/config.toml`
53+
for the user configuration.
54+
55+
Any data stored here this will be added to the `metadata.binpb` that was created
56+
in [secure config](secure-config.md) (that is, we will add additional fields to
57+
the `Metadata` struct). This will ensure that we don't suffer from the "zip file
58+
problem".
59+
60+
### Security
61+
62+
#### Trust levels
63+
64+
We will add the following fields to metadata:
65+
```
66+
enum TrustLevel {
67+
// There's no "optional" in protos. It just returns the zero value.
68+
UNSET = 0;
69+
// The user wishes to completely ignore the managed config file.
70+
DISABLED = 1;
71+
// In trusted mode, we directly read from the managed config file.
72+
// This presents a security risk, so the user is expected to only do this
73+
// for a repo they trust.
74+
TRUSTED = 2;
75+
// In notify mode, the user is expected to manually replicate any changes
76+
// they want from the managed config to the repo config.
77+
NOTIFY = 3;
78+
}
79+
80+
message Metadata {
81+
// previous fields
82+
83+
// The trust level associated with this repo.
84+
TrustLevel trust_level = 2;
85+
// The mtime last seen associated with the config.toml file.
86+
Timestamp last_modified = 3;
87+
// The content of the most recently seen config.toml file.
88+
string last_managed_config = 4;
89+
}
90+
```
91+
92+
Note that although the `Metadata` struct is stored for both the repo and the
93+
workspace, this will only be used for the repository, not the workspace.
94+
95+
#### User interface
96+
97+
Everything here only becomes relevant when a repo has a managed config. If it
98+
does not have a managed config, we skip everything here.
99+
100+
##### Unset trust
101+
102+
If a repository has unset trust, we first use `ui.can_prompt()` to determine
103+
whether we are in a TUI.
104+
105+
* If we are in a TUI, we ask the user what trust level they would like.
106+
* If we are not in a TUI, we warn them that we are ignoring the repo config (we
107+
return `DISABLED`), and that they can run `jj` in a terminal to configure this.
108+
109+
##### Disabled
110+
111+
Nice and easy. We just completely ignore the managed config file.
112+
113+
##### Trusted
114+
115+
Nice and easy. We just read the managed config file.
116+
117+
##### Notify
118+
119+
Notify is not a part of the MVP, and will be added later, in order to keep the
120+
MVP simple.
121+
122+
Roughly speaking, we will need to:
123+
124+
* if `mtime(repo_config_file) < mtime(managed config)`
125+
* The repo config is "out of date"
126+
* We print a warning, potentially diffing `last_managed_config` with the
127+
actual managed config on disk.
128+
* otherwise, the repo config isn't out of date
129+
* `last_modified = mtime(managed config)`
130+
* `last_managed_config = read(managed config)` (if mtime changed)
131+
132+
The notify option has a rather painful UX when it comes to keeping it in sync
133+
(particularly for a user who constantly switches between different branches
134+
synced to different versions), but I choose it for several reasons. The first is
135+
that the vast majority of users will simply select to blindly trust the repo.
136+
The only people who will choose this option are the very security conscious, and
137+
this is by far the most secure mechanism. Secondly, it is so much simpler than
138+
an approval based mechanism, as we don’t need to worry about things such as
139+
workspaces being synced to different places. It provides far fewer edge cases.
140+
141+
##### Manually changing
142+
143+
These options will also be able to be manually set via `jj config managed
144+
--disable/notify/trust`. This is not a part of the MVP.
145+
146+
### Where to read from
147+
148+
This is the trickiest part of the proposal. Consider the following workflow:
149+
150+
```
151+
jj new main@origin
152+
jj ...
153+
jj new lts-branch@origin
154+
```
155+
156+
There are some edge cases we need to consider:
157+
158+
* `lts-branch` may have existed before the config was added
159+
* `lts-branch` may have a different copy of the managed config
160+
161+
The naive assumption would be that you want to read the config from `@`, as the
162+
config will always match the version of the code you're using. However, it turns
163+
out that some things want to refer to `@`, while others want to read the config
164+
from `trunk()`.
165+
166+
Consider several different use cases:
167+
168+
* My formatter was previously `clang-format --foo`, but the option `--foo` was
169+
deprecated in the latest version of `clang-format`
170+
* Here, you want to read from `trunk()`
171+
* My formatter was previously `$repo/formatter --foo`, but the option `--foo`
172+
was deprecated in the latest version of `formatter`
173+
* Here, you want to read from `@`
174+
* We decide to split long lines and add a new formatter (or pre-upload hook)
175+
config `formatter --line-length=80`
176+
* Here, you probably want to read from `@`
177+
* We decide to add a pre-upload check that validates that all commit
178+
descriptions contain a reference to a bug
179+
* This should be applied to old branches as well, so you want `trunk()`
180+
* We add a new helpful alias / revset
181+
* This should be applied to old branches as well, so you want `trunk()`
182+
* We move our formatter
183+
* If it’s external, you want to read from `trunk()`
184+
* If it’s internal, you want to read from `@`
185+
186+
All in all, you can see a general pattern.
187+
188+
* If something refers to an in-repo tool, you **probably** want the config to
189+
be read from `@`
190+
* Otherwise, you **probably** want to read from `trunk()`
191+
* I say probably, because the split long lines example doesn’t conform to this
192+
rule.
193+
194+
#### Problematic examples
195+
196+
This is problematic with `trunk()` because if you add the `--reorder-hooks` and
197+
then checkout `lts-branch` it will incorrectly attempt to reorder imports
198+
199+
```
200+
[fix.tools.rustfmt]
201+
command = ["rustfmt", "--reorder-imports"]
202+
```
203+
204+
##### Solution 1: formatter config
205+
206+
In practice, it is highly unlikely that a formatter config would be written that
207+
way. Far more likely, you would see an entry in `config.toml` like:
208+
209+
```
210+
[fix.tools.rustfmt]
211+
command = ["rustfmt"]
212+
```
213+
214+
`.rustfmt.toml`:
215+
```
216+
reorder_imports = true
217+
```
218+
219+
This simply works out of the box, since the formatter is reading the config from
220+
`@`'s `.rustfmt.toml`
221+
222+
##### Solution 2 (more general but convoluted): Wrapper script
223+
224+
As long as the formatter is in-repo, we can just write a wrapper script which
225+
does this for us.
226+
227+
```
228+
[fix.tools.rustfmt]
229+
command = ["rustfmt.py"]
230+
```
231+
232+
`rustfmt.py`:
233+
```
234+
os.execv(["rustfmt", "--reorder-imports"])
235+
```
236+
237+
#### Solution 3 (for scripts that need to run at trunk)
238+
239+
If you write a script for which the API keeps changing, eg. you add / remove
240+
flags to it, you can do something like this:
241+
242+
```
243+
[aliases]
244+
upload = ["util", "exec", "--", "bash", "-c", "python3", "-c", "$(jj file show -r 'trunk()' upload.py)"]
245+
```
246+
247+
#### Decision: Trunk vs @
248+
249+
`@` and `trunk()` are the only two reasonable candidates as places to read from,
250+
IMO. I personally believe that if only one option is available, `trunk()` would
251+
be much more appropriate, for the reasons specified above.
252+
253+
However, @pmetzger has pointed out that in a future world where git isn’t the
254+
backend, this decision may come back to bite us (as build tools may be checked
255+
in to the build). This is already the case for some git repositories such as the
256+
android repo. To resolve this easily, I propose supporting both.
257+
258+
To achieve this, we would split the managed config file in two. We would now
259+
have:
260+
261+
* `$REPO/.config/jj/working_copy_config.toml`, which would always be read from
262+
`@`
263+
* `$REPO/.config/jj/trunk_config.toml`, which would always be read from `trunk()`
264+
265+
Note that we will only implement one of these in the MVP, and choose to do the
266+
other later (we will probably do `@` first since it's simpler to implement).
267+
268+
## Alternatives considered
269+
270+
### Approval mechanisms
271+
272+
We considered an approval based mechanism where when you saw a config you hadn't
273+
seen before, you would appprove or reject it. It turned out to have a lot of
274+
edge cases though, and was extremely complex (for both the user and the
275+
implementer). For example, what happens if you approve some config, then sync to
276+
an old commit? What happens if you have two workspaces synced to different
277+
commits. What happenps if you want to approve some of the changes but not
278+
others?

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ nav: # This lists all the files that become part of the documentation
179179
- Tracking branches: 'design/tracking-branches.md'
180180
- Copy tracking and tracing: 'design/copy-tracking.md'
181181
- Secure config: 'design/secure-config.md'
182+
- Managed config: 'design/managed-config.md'
182183

183184
- Development roadmap: 'roadmap.md'
184185
- Changelog: 'changelog.md'

0 commit comments

Comments
 (0)