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
//! GH-377 + forjar#469: refuse to join one stack's config to another stack's
//! state dir — without refusing the layout where N stacks legitimately share
//! one.
//!
//! THE DEFECT. `-f/--file` defaults to `forjar.yaml` in the CWD while
//! `--state-dir` is a separate argument, so the two can name different stacks
//! and nothing noticed. Measured on 1.22.0: run from a directory holding stack
//! B's config, `forjar undo --state-dir <stack A's state>` exited 0, diffed
//! stack A's generations (printing `~ demo_file (box): will be updated`, a
//! resource B does not declare), then converged stack B's resources against the
//! host and re-stamped A's `forjar.lock.yaml` as `name: stack-bravo` — erasing
//! the only evidence that the dirs had ever belonged to different stacks.
//!
//! The plan shown and the work done were about different stacks. That is what
//! makes `undo` a refusal rather than a warning: `apply` at least does exactly
//! what its two arguments say.
//!
//! THE SIGNAL, forjar#469. The first guard compared the lock's single `name:`
//! against the config's. That name is written by whichever stack applied LAST,
//! so in the supported many-stacks-one-state-dir layout (paiml/infra: six
//! `machines/<m>/forjar.yaml`, one `state/`) it named a different stack on
//! every second apply and `undo` refused a layout `apply` supports.
//!
//! The identity is now the map `stacks: {name -> StackStamp}`, keyed exactly as
//! the lock's machine sections are, and this guard asks the SAME question
//! `apply` warns on — `state::stack_conflict` — so the two cannot disagree
//! about what "wrong stack" means. `undo` refuses where `apply` warns, plus one
//! case only `undo` has: a name this dir has no record of at all, whose
//! generations therefore belong to somebody else.
//!
//! FAIL-OPEN IS LOAD-BEARING, NOT LAZINESS. A state dir with no readable
//! `forjar.lock.yaml` is reachable in normal use: `forjar rollback
//! --generation 0 --yes` restores a generation that predates the first global
//! lock and leaves the state dir without one. Refusing there would brick a
//! state dir that works today, so absence, unreadability and a lock with no
//! stamps at all all ALLOW.
use crate::core::{state, types};
use std::path::{Path, PathBuf};
/// Absolute path when it can be resolved. `-f` defaults to the relative
/// `forjar.yaml`, and printing that bare name is what kept the defect invisible.
fn shown(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
/// The global lock, when this dir has one that parses. Absence, unreadability
/// and an unknown schema all yield `None`, and both guards below read that as
/// ALLOW — the fail-open half documented at the top of this module.
fn recorded_lock(state_dir: &Path) -> Option<types::GlobalLock> {
state::load_global_lock(state_dir).ok().flatten()
}
/// Refuse when `state_dir`'s stamps say this config does not own what `undo`
/// would replay.
///
/// `verb` names the command in the message ("undo", "undo --resume").
pub(super) fn check_state_dir_owner(
verb: &str,
config: &types::ForjarConfig,
file: &Path,
state_dir: &Path,
) -> Result<(), String> {
let Some(lock) = recorded_lock(state_dir) else {
return Ok(());
};
if lock.stacks.is_empty() {
return Ok(());
}
let machines: Vec<String> = config.machines.keys().cloned().collect();
// The same condition `apply` warns on, asked of the same map: the same name
// from a different `-f`, or a machine another stack owns.
if let Some(conflict) =
state::stack_conflict(&lock, state_dir, &config.name, Some(file), &machines)
{
let detail = format!("stack '{}' {conflict}", config.name);
return Err(refusal(verb, &config.name, &lock, &detail, file, state_dir));
}
if lock.stamp_for(&config.name).is_some() {
return Ok(());
}
// The name in the stamps is not the only name this state dir has answered
// to.
//
// `undo` replays a generation's RECORDED config, and that replay stamps the
// global lock with the name the stack carried BACK THEN. So undoing across
// a rename rewrites the lock to the historical name, and the next undo
// compared the operator's current name against it and refused — one config,
// one state dir, one operator, no mistake. Worse, the remedy this printed
// ("run `forjar apply` once to re-stamp") converges the host FORWARD,
// destroying the very undo in progress.
//
// A renamed stack's own history is recorded in its generations; a genuinely
// foreign stack's is not. So accept a name this state dir has applied under
// before, and keep refusing everything else.
if applied_under_before(state_dir, &config.name) {
return Ok(());
}
let detail = format!(
"state dir {} has no record of stack '{}' (it was applied by: {}), so {verb} would \
replay another stack's generations against this config's resources.",
shown(state_dir).display(),
config.name,
stack_names(&lock),
);
Err(refusal(verb, &config.name, &lock, &detail, file, state_dir))
}
/// The stacks this dir has stamps for, quoted, in the order they were recorded.
fn stack_names(lock: &types::GlobalLock) -> String {
quoted(&state::stack_names(lock))
}
/// `'a', 'b'` — the stack list as every message in this module prints it.
fn quoted(names: &[String]) -> String {
names
.iter()
.map(|n| format!("'{n}'"))
.collect::<Vec<_>>()
.join(", ")
}
/// The refusal. It names both sides, both absolute paths, what would have
/// happened, and the two ways out — including the one that is not a mistake at
/// all, a stack that was renamed.
///
/// `detail` is the sentence `apply` warns with, verbatim, so an operator who
/// read the warning recognises the refusal.
fn refusal(
verb: &str,
config_name: &str,
lock: &types::GlobalLock,
detail: &str,
file: &Path,
state_dir: &Path,
) -> String {
format!(
"refusing to {verb}: --state-dir belongs to a different stack.\n \
config: '{config_name}' ({})\n \
state dir: {} ({})\n\
{detail}\n\
Point -f at the config that owns that state, or --state-dir at the state that \
belongs to this config. If '{config_name}' is a stack this dir knows under another \
name, run `forjar apply` once to re-stamp the state dir, then {verb} again",
shown(file).display(),
stack_names(lock),
shown(state_dir).display(),
)
}
/// Has this state dir ever been applied under `name`?
///
/// Reads the stack name out of each generation's recorded config. Only the name
/// is needed, so the document is parsed as loose YAML rather than a full
/// `ForjarConfig` — a generation recorded by an older or newer forjar must not
/// be able to turn this into a refusal.
fn applied_under_before(state_dir: &Path, name: &str) -> bool {
let gens = state_dir.join("generations");
let Ok(entries) = std::fs::read_dir(&gens) else {
return false;
};
entries.flatten().any(|e| {
std::fs::read_to_string(e.path().join(super::undo_replay::APPLIED_CONFIG))
.ok()
.and_then(|s| serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&s).ok())
.and_then(|v| v.get("name")?.as_str().map(str::to_string))
.is_some_and(|n| n == name)
})
}