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
//! GH-377: refuse to join one stack's config to another stack's state dir.
//!
//! 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. `state/forjar.lock.yaml` carries `name:`, copied verbatim from
//! the config's top-level `name` on every apply (`state::update_global_lock`).
//! `name` is required — parsing fails without it — never templated, never
//! param-substituted, and untouched by include merges. It is the only stable
//! stack identity the state dir records, and it is already there.
//!
//! 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 an empty name all
//! ALLOW; only a name that is present and different refuses.
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())
}
/// Refuse when `state_dir` was last applied by a stack other than `config`.
///
/// `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 Ok(Some(lock)) = state::load_global_lock(state_dir) else {
return Ok(());
};
if lock.name.is_empty() || lock.name == config.name {
return Ok(());
}
// The name in the lock 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(());
}
Err(mismatch_error(
verb,
&config.name,
&lock.name,
file,
state_dir,
))
}
/// The refusal. It names both stacks, 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.
fn mismatch_error(
verb: &str,
config_name: &str,
owner: &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: '{owner}' ({})\n\
The generations there record what '{owner}' applied, but {verb} would re-converge \
the host from '{config_name}' — every resource '{config_name}' declares would be \
applied against state that does not describe it.\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 '{owner}' renamed, run \
`forjar apply` once to re-stamp the state dir, then {verb} again",
shown(file).display(),
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)
})
}