1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
//! Read-only discovery of the declarations an incoming configuration would
//! activate. No live config is replaced, trusted, rendered, or executed here.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use eyre::{Result, bail};
use super::layout::{Roots, is_configuration};
use super::reconcile::{Object, PathPlan};
use crate::config::ConfigMap;
use crate::config::config_file::mise_toml::MiseToml;
use crate::system::history::shadow::HistoryRepo;
use crate::system::history::tracked::TrackedSet;
pub(super) fn prospective(
repo: &HistoryRepo,
state_dir: &Path,
tracked: &TrackedSet,
plans: &[PathPlan],
) -> Result<TrackedSet> {
let roots = Roots::current();
let incoming: BTreeMap<PathBuf, Option<Object>> = plans
.iter()
.filter(|plan| is_configuration(&plan.branch_path))
.filter_map(|plan| {
Some((
roots.locate(&plan.branch_path).path()?.to_path_buf(),
plan.apply.clone()?,
))
})
.collect();
if incoming.is_empty() {
return Ok(tracked.clone());
}
let paths: BTreeSet<PathBuf> = incoming.keys().cloned().collect();
let candidates = crate::config::config_files_with_incoming(&roots.config_dir, &paths);
// Validate every body we will write, even a conf.d file not selected
// by this machine's explicit MISE_GLOBAL_CONFIG_FILE override.
let mut incoming_files = ConfigMap::new();
for (path, object) in &incoming {
// The config directory also carries template and other sources.
if !candidates.contains(path) && crate::env::MISE_GLOBAL_CONFIG_FILE.as_ref() != Some(path)
{
continue;
}
let Some((mode, oid)) = object else {
continue;
};
if mode != "100644" && mode != "100755" {
bail!(
"incoming configuration must be a regular file: {}",
path.display()
);
}
let body = String::from_utf8(repo.cat_object(oid)?)?;
let parsed = MiseToml::for_history_preflight(&body, path)?;
if parsed
.history_config()
.is_some_and(|history| history.origin.is_some())
{
bail!(
"incoming history.origin is machine-local configuration; remove it from the shared setup and use `mise dot origin set` on this machine ({})",
path.display()
);
}
incoming_files.insert(
path.clone(),
Arc::new(parsed) as Arc<dyn crate::config::config_file::ConfigFile>,
);
}
crate::system::files::validate_incoming_files(&incoming_files)?;
let global = match &*crate::env::MISE_GLOBAL_CONFIG_FILE {
Some(path) => vec![path.clone()].into_iter().collect(),
None => candidates,
};
let mut files = ConfigMap::new();
let mut excludes = vec![];
for path in crate::config::system_config_files()
.into_iter()
.chain(global)
{
if path.extension().is_none_or(|ext| ext != "toml") {
continue;
}
let body = match incoming.get(&path) {
Some(Some((mode, oid))) if mode == "100644" || mode == "100755" => {
String::from_utf8(repo.cat_object(oid)?)?
}
Some(Some(_)) => bail!(
"incoming configuration must be a regular file: {}",
path.display()
),
Some(None) => continue,
None => match std::fs::read_to_string(&path) {
Ok(body) => body,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
Err(err) => return Err(err.into()),
},
};
let parsed = MiseToml::for_history_preflight(&body, &path)?;
if let Some(history) = parsed.history_config() {
excludes.extend(history.exclude);
}
files.insert(
path,
Arc::new(parsed) as Arc<dyn crate::config::config_file::ConfigFile>,
);
}
// File composition expects highest precedence first.
files.reverse();
crate::system::files::validate_incoming_files(&files)?;
let requests = crate::system::files::files_from_config_files(&files);
crate::system::files::validate_composed_file_footprints(&requests)?;
let mut declarations = TrackedSet {
exclude: excludes,
..Default::default()
};
declarations.add_requests(requests);
if let Some(invalid) = declarations.invalid.first() {
bail!("{}: {}", invalid.path, invalid.reason);
}
// **An incoming exclusion that cannot be applied is refused here,
// not merely compiled.** This line used to fail through
// `exclude_set()?` because building the matcher errored on an
// unusable pattern; it now records the pattern instead, so asking
// only for the matcher would accept the config and write it. A pull
// that applies it would then capture, and could publish, exactly the
// files the broken rule was written to keep out. The guards on the
// live set do not cover this: they read the local list, and this is
// the incoming one. Failing here is what `run` turns into an
// `InvalidIncoming` conflict with the plan left unapplied.
declarations.refuse_unusable_exclusions()?;
// finished the way `TrackedSet::from_config` finishes a set, so it can
// be resolved the way a live one is. Recipients are deliberately left
// unset: `reconcile` then keeps the saved ones, and a batch is not the
// place to decide who can decrypt.
declarations.manifest.exclude = declarations.exclude.clone();
declarations.declarations = Some(declarations.manifest.clone());
// **The prospective set is built the way the live set is built, so the
// two cannot disagree about what a path excludes.**
// `TrackedSet::effective` is `from_config` and then
// `enrollment::resolve`, and resolve is where an entry's exclusions
// come from when the local declaration does not carry them: the saved
// manifest another machine published. Copying the incoming
// declarations' lists onto the live entries read configuration alone,
// so a machine declaring `~/.ssh` with no `exclude` of its own, while
// the committed manifest excludes `id_*`, had that list replaced with
// an empty one here — `~/.ssh/id_rsa` then looked managed, its absence
// upstream read as a deletion, and the pull removed a private key.
// Reconcile decides per entry, and for the global list, where each one
// comes from, and it is now the only thing that decides it.
//
// Read-only: `resolve` reads the repository and the declarations
// cache, and writes neither.
crate::system::history::enrollment::resolve(state_dir, repo, &declarations, &[], &[])
}
/// Required source files must exist in the complete proposed write set or
/// already be available locally. A queued deletion is not an available source.
pub(super) fn sources(repo: &HistoryRepo, tracked: &TrackedSet, plans: &[PathPlan]) -> Result<()> {
let roots = Roots::current();
for source in &tracked.required_sources {
let planned = plans
.iter()
.find(|plan| roots.locate(&plan.branch_path).path() == Some(source.as_path()));
match planned.and_then(|plan| plan.apply.as_ref()) {
Some(Some((mode, oid))) => {
if mode != "100644" && mode != "100755" {
bail!(
"required source is not a regular file: {}",
source.display()
);
}
repo.cat_object(oid)?;
}
Some(None) => bail!(
"incoming setup deletes required source {}",
source.display()
),
None if !source.exists() && !has_incoming_child(&roots, source, plans) => {
bail!(
"incoming setup is missing required source {}",
source.display()
);
}
None => {}
}
}
Ok(())
}
fn has_incoming_child(roots: &Roots, path: &Path, plans: &[PathPlan]) -> bool {
plans.iter().any(|plan| {
plan.apply.as_ref().is_some_and(Option::is_some)
&& roots
.locate(&plan.branch_path)
.path()
.is_some_and(|p| p.starts_with(path))
})
}