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
203
204
205
206
207
208
209
210
//! Where a drift run gets its baselines — and what it does when there are none.
//!
//! Split out of `drift.rs` (forjar#385) so the one place that decides whether a
//! state directory is ABSENT or UNREADABLE is a file you can read in full.
//!
//! That distinction is the whole point. `read_dir` failing was treated as one
//! fault and killed the run, so paiml/infra's nightly drift lane — which
//! gitignores `state/`, and therefore checks out without one on every CI run —
//! had never measured anything:
//!
//! ```text
//! FAIL gx10 forjar drift exited 1: error: cannot read state dir .../infra/state
//! ```
//!
//! `NotFound` means "never applied from here", which is routine and survivable:
//! `type: task` resources carry an ASSERTION rather than a baseline, so the run
//! can still ask the host about them (`tripwire::drift::lockless`). Every other
//! `read_dir` error means the directory is there and forjar cannot read it —
//! wrong mode, not a directory, a dead mount — which is a broken host and stays
//! fatal. Collapsing the second into the first would turn a real fault into a
//! quiet partial answer, which is the defect class this fix exists to close.
use crate::core::{state, types};
use std::path::Path;
/// Machine directory names under `state_dir`, in read order, honouring an
/// optional single-machine filter. Unreadable entries and non-directories are
/// skipped; whether an empty result is an error is left to the caller.
///
/// `Ok(None)` is the ABSENT state dir — no lock exists anywhere, so there is
/// nothing here to enumerate and the caller takes the lockless path.
pub(super) fn machine_state_dirs(
state_dir: &Path,
machine_filter: Option<&str>,
scope: Option<&[String]>,
) -> Result<Option<Vec<String>>, String> {
let entries = match std::fs::read_dir(state_dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(format!(
"cannot read state dir {}: {}",
state_dir.display(),
e
))
}
};
let names = entries
.flatten()
.filter(|entry| entry.path().is_dir())
.map(|entry| entry.file_name().to_string_lossy().to_string())
.filter(|name| in_this_run(name, machine_filter, scope))
.collect();
Ok(Some(names))
}
/// Whether one state directory belongs to the run being asked for.
///
/// `machine_filter` is `-m`, and has always been here. `scope` is forjar#488:
/// the machines the loaded config declares.
///
/// A stack this config does not declare is not this run's business.
/// `check_machine_drift` resolves a stack with `config.machines.get(name)`; a
/// miss falls to the lock-only arm, which has no machine and therefore reads
/// the recorded path on the LOCAL box. On a fleet that is a different
/// computer's file, and the mismatch is permanent (forjar#485). Excluding the
/// stack HERE means it is never opened at all, so there is no arm to fall to.
fn in_this_run(name: &str, machine_filter: Option<&str>, scope: Option<&[String]>) -> bool {
if machine_filter.is_some_and(|filter| name != filter) {
return false;
}
scope.is_none_or(|declared| declared.iter().any(|m| m == name))
}
/// `(machine_name, lock)` pairs from the state directory, or `None` when the
/// state directory is absent.
pub(super) fn collect_machine_locks(
state_dir: &Path,
machine_filter: Option<&str>,
scope: Option<&[String]>,
) -> Result<Option<Vec<(String, types::StateLock)>>, String> {
refuse_out_of_scope(machine_filter, scope)?;
let Some(names) = machine_state_dirs(state_dir, machine_filter, scope)? else {
return Ok(None);
};
let mut locks = Vec::new();
for name in names {
if let Some(lock) = state::load_lock(state_dir, &name)? {
locks.push((name, lock));
}
}
// A FILTER THAT MATCHES NOTHING IS AN ERROR, NOT A CLEAN BILL OF HEALTH.
//
// `-m <machine>` narrowed the scan by name; if nothing matched, this
// returned an empty list and the caller reported "No drift detected." over
// ZERO machines — with `--tripwire` still exiting 0. So a typo in a cron'd
// `forjar drift --tripwire -m intel` silently stopped checking anything and
// reported healthy forever. Ledger id
// drift-tripwire-false-green-on-unknown-machine, confirmed at 1.12.3 and
// still live at 1.16.0.
if let Some(filter) = machine_filter {
// Distinguish "this machine does not exist" from "this machine exists
// but has no state yet". Only the FIRST is an error: a machine dir with
// no lock is a machine that has simply never been applied, and failing
// there would break `drift -m <new-machine>` before its first apply.
// Keying on lock-presence instead conflated the two and broke
// test_fj017_drift_machine_filter, which sets up exactly that case.
let dir_exists = state_dir.join(filter).is_dir();
if !dir_exists {
return Err(unknown_machine(state_dir, filter));
}
}
Ok(Some(locks))
}
/// A `-m` THAT THE CONFIG DOES NOT DECLARE IS A REFUSAL, NOT AN EMPTY SCAN.
///
/// With forjar#488's scope in place, `-m other` where `other` is a real state
/// directory outside this config would pass the existence check and then match
/// nothing, and the caller would report "No drift detected." over ZERO
/// machines. That is the same false green the unknown-machine refusal already
/// exists to prevent, reached by a different door.
pub(super) fn refuse_out_of_scope(
machine_filter: Option<&str>,
scope: Option<&[String]>,
) -> Result<(), String> {
let (Some(filter), Some(declared)) = (machine_filter, scope) else {
return Ok(());
};
if declared.iter().any(|m| m == filter) {
return Ok(());
}
Err(out_of_scope_machine(filter, declared))
}
/// The refusal for a `-m` naming a machine this config does not declare.
fn out_of_scope_machine(filter: &str, declared: &[String]) -> String {
format!(
"machine '{filter}' is not declared by this config, so there is nothing \
here to check for it.\n This config declares: {}\n \
Point -f at the config that declares '{filter}', or pass --all-stacks \
to check every stack in the state dir (forjar#488).",
if declared.is_empty() {
"no machines".to_string()
} else {
declared.join(", ")
}
)
}
/// The refusal for a `-m` that names no machine directory.
fn unknown_machine(state_dir: &Path, filter: &str) -> String {
let known: Vec<String> = std::fs::read_dir(state_dir)
.map(|es| {
es.flatten()
.filter(|e| e.path().is_dir())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect()
})
.unwrap_or_default();
format!(
"unknown machine '{filter}' — it has no directory in {}, so NOTHING was checked. Known: {}",
state_dir.display(),
if known.is_empty() {
"(none)".to_string()
} else {
known.join(", ")
}
)
}
#[cfg(test)]
mod tests {
use super::*;
/// The forjar#385 case: absent is not an error, it is `None`.
#[test]
fn an_absent_state_dir_is_not_an_error() {
let d = tempfile::tempdir().unwrap();
let missing = d.path().join("no-such-state");
assert_eq!(machine_state_dirs(&missing, None, None), Ok(None));
assert!(matches!(
collect_machine_locks(&missing, None, None),
Ok(None)
));
}
/// THE LINE. A state path that exists and is not a readable directory is a
/// broken host, and must NOT be reported as "never applied from here".
#[test]
fn a_state_path_that_is_a_file_is_still_fatal() {
let d = tempfile::tempdir().unwrap();
let file = d.path().join("state");
std::fs::write(&file, "not a directory").unwrap();
let err = machine_state_dirs(&file, None, None).unwrap_err();
assert!(err.contains("cannot read state dir"), "{err}");
}
/// An empty-but-present state dir still enumerates to zero machines, which
/// is a different answer from "there is no state dir".
#[test]
fn a_present_empty_state_dir_enumerates_nothing() {
let d = tempfile::tempdir().unwrap();
assert_eq!(
machine_state_dirs(d.path(), None, None),
Ok(Some(Vec::new()))
);
}
}