aion-package 0.27.1

Archive validation, content hashing, and namespacing for Aion workflow packages.
Documentation
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
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! Reading the PRIOR archive form of a declared command.
//!
//! # Why this exists
//!
//! v0.27.0 reshaped [`DeclaredCommandContract`] around body LINES (the just
//! recipe surface). Archives deployed under the prior form — a `program`
//! word list plus `args` slots, emitted by the clause spelling this language
//! no longer has — are already durable in real stores, and the release rule
//! is that a store migrates itself or nothing ships: boot recovery, serve
//! dispatch and the deployed census all sit on the READ path of those
//! archives, and a contract that refused to deserialize there would make a
//! deployed package unreadable by upgrading the binary.
//!
//! So the DESERIALIZER accepts both forms. The current form reads as
//! written. The prior form is translated at read time into the current
//! shape, faithfully where the two surfaces mean the same thing:
//!
//! - the `program` words become leading literal argv slots and the `args`
//!   slots follow them, as ONE body line — exactly the argv the prior
//!   executor built, in the same order, with each slot's recorded
//!   `admits_leading_dash` fact preserved;
//! - a parameter default that is literal text carries over unchanged;
//! - a literal environment binding carries over; the prior `hardening path`
//!   value becomes a literal `PATH` binding, which is what it did — the
//!   executor clears the child environment and sets `PATH`, and a declared
//!   binding of that name overrides it;
//! - the prior `timeout` clause translates to nothing, deliberately: the
//!   enforcement machinery was deleted with the surface, and carrying a
//!   number nothing enforces would be a claim the runtime cannot honour.
//!   The drop is LOGGED at open, because an upgrade that silently turns a
//!   bounded command into an unbounded one is a silence the operator pays
//!   for.
//!
//! # The constructs with no faithful translation
//!
//! Four prior constructs have no current spelling at all. None of them can
//! be translated into something that still means what its author wrote:
//!
//! - an environment binding whose value interpolates a parameter — an
//!   environment value is literal document text now, with no parameters in
//!   scope;
//! - a `hardening path` whose value interpolates a parameter, which is the
//!   same construct wearing the name `PATH`;
//! - a LIST parameter — the prior executor splatted a sole-hole list value
//!   into one argv element per item, and the current surface renders one
//!   fill as exactly one element, so there is no argv a list could produce;
//! - a parameter default that interpolates another parameter — a default is
//!   literal text now, so there is no value left to fall back on.
//!
//! All four take the SAME path, and it is deliberately not a read failure.
//! The construct is named onto
//! [`DeclaredCommandContract::prior_form_refusal`], each occurrence is warned
//! about once at open (so the operator hears it at upgrade time, not at three
//! in the morning on a schedule), the archive still opens, lists and censuses
//! everywhere, and only an attempt to EXECUTE the command refuses — naming
//! the command, the construct and the one cure, which is to redeploy the
//! document under the current spelling
//! ([`super::error::RenderError::PriorFormUnrenderable`]).
//!
//! This module is the whole compatibility surface: nothing else in the crate
//! knows the prior form exists, and the emitter can never produce it.

use serde::Deserialize;
use serde::de::Error as _;

use super::contract::{
    ArgvSlot, CommandLineContract, CommandParameterContract, DeclaredCommandContract,
    EnvBindingContract,
};
use super::template::{FillPiece, FillTemplate};

// This implementation buffers the entry into a `serde_json::Value` and reads
// the shape off the buffered keys, which REQUIRES a self-describing format.
// The archive's contract entry is JSON and always has been; a future compact
// codec (one whose deserializer cannot answer `deserialize_any`) would make
// the buffering step fail rather than mis-route, but it would also silently
// take away the only signal that tells the two forms apart — so a codec change
// here is a change to this router, not a change underneath it.
impl<'de> Deserialize<'de> for DeclaredCommandContract {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        // The two shapes are told apart by the field that names each: the
        // prior form always carries `program` (no skip, so it is present even
        // when empty) and never `lines`; the current form always carries
        // `lines` and never `program`. Routing on the bytes keeps each shape's
        // own field errors precise, where an untagged union would report only
        // "no variant matched".
        let value = serde_json::Value::deserialize(deserializer)?;
        let has_program = value.get("program").is_some();
        let has_lines = value.get("lines").is_some();
        if has_program && has_lines {
            // Neither reading is defensible: taking the prior path would
            // discard the body lines in silence, and taking the current path
            // would discard the program words. An entry no emitter of either
            // form could have written is refused by name.
            return Err(D::Error::custom(
                "this declared command carries both `program` (the prior archive form) and \
                 `lines` (the current form); no emitter writes both, and reading either one \
                 would silently discard the other, so the entry is refused rather than guessed",
            ));
        }
        if has_program {
            let prior = PriorForm::deserialize(&value).map_err(D::Error::custom)?;
            return Ok(prior.translated());
        }
        CurrentForm::deserialize(&value)
            .map_err(D::Error::custom)
            .map(CurrentForm::into_contract)
    }
}

/// The current wire shape, mirrored for derived deserialization (the real
/// struct's `Deserialize` is the hand-written router above).
///
/// `deny_unknown_fields` because this mirror names every field the current
/// `Serialize` impl writes, and the only writer of a current-form entry is
/// that impl: a key this shape does not name was never emitted here, and
/// accepting it would let a hand-edited or half-translated entry read as
/// something narrower than it says.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CurrentForm {
    name: String,
    #[serde(default)]
    parameters: Vec<CommandParameterContract>,
    lines: Vec<CommandLineContract>,
    #[serde(default)]
    env: Vec<EnvBindingContract>,
    #[serde(default)]
    cwd: Option<String>,
    #[serde(default)]
    prior_form_refusal: Option<String>,
}

impl CurrentForm {
    fn into_contract(self) -> DeclaredCommandContract {
        DeclaredCommandContract {
            name: self.name,
            parameters: self.parameters,
            lines: self.lines,
            env: self.env,
            cwd: self.cwd,
            prior_form_refusal: self.prior_form_refusal,
        }
    }
}

/// One parameter as the prior form declared it: the default is a fill
/// TEMPLATE rather than literal text, and the shape flag says whether the
/// parameter took a LIST of values.
///
/// `list` is required, not defaulted: the prior emitter wrote the flag on
/// every parameter it ever emitted (a plain `bool` with no skip), and
/// defaulting it would mean reading a list parameter as a single-value one —
/// which is the exact silence this reader exists to break.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PriorParameter {
    name: String,
    list: bool,
    #[serde(default)]
    default: Option<FillTemplate>,
}

/// One environment binding as the prior form declared it: the value is a
/// fill template.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PriorEnv {
    name: String,
    value: FillTemplate,
}

/// The prior wire shape. The `timeout_ms`/`timeout_owner` pair is read only
/// to be REPORTED: the enforcement machinery was deleted with the surface,
/// so the bound translates to nothing — and an upgrade that silently turned
/// a bounded command into an unbounded one would be a silence the operator
/// pays for, so the drop is logged at open.
///
/// `deny_unknown_fields` because these nine names are the COMPLETE field set
/// the prior form ever serialized, verified against the released source of
/// both commits that ever emitted it (`e7a4095dd` and `a5ce33a78`, unchanged
/// between them): `name`, `parameters`, `program`, `args`, `env`, `cwd`,
/// `hardened_path`, `timeout_ms`, `timeout_owner`. A key outside that set
/// cannot have come from a prior-form emitter, so reading it as a prior-form
/// command would be reading something else entirely.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PriorForm {
    name: String,
    #[serde(default)]
    parameters: Vec<PriorParameter>,
    program: Vec<String>,
    #[serde(default)]
    args: Vec<ArgvSlot>,
    #[serde(default)]
    env: Vec<PriorEnv>,
    #[serde(default)]
    cwd: Option<String>,
    #[serde(default)]
    hardened_path: Option<FillTemplate>,
    #[serde(default)]
    timeout_ms: Option<i64>,
    #[serde(default)]
    timeout_owner: Option<String>,
}

impl PriorForm {
    /// Translate into the current shape, per the module rules.
    fn translated(self) -> DeclaredCommandContract {
        let Self {
            name,
            parameters,
            program,
            args,
            env,
            cwd,
            hardened_path,
            timeout_ms,
            timeout_owner,
        } = self;

        // An upgrade must not change behaviour in silence: the operator whose
        // command just went from bounded to unbounded learns it here, once
        // per open, from the product — before anything dispatches.
        if let Some(timeout_ms) = timeout_ms {
            tracing::warn!(
                operation = "prior_form_command_read",
                command = %name,
                timeout_ms,
                owner = timeout_owner.as_deref().unwrap_or("unstated"),
                "this declared command was deployed with a time limit of its own, and a command \
                 no longer carries one: the limit is gone and the command now runs for as long \
                 as it takes. Bound the work with the workflow's activity timeout instead, then \
                 redeploy the document"
            );
        }

        let mut untranslated = Untranslated {
            command: &name,
            first: None,
        };

        let mut slots: Vec<ArgvSlot> = program
            .into_iter()
            .map(|word| ArgvSlot {
                fill: FillTemplate::literal(word.clone()),
                label: word,
                // The prior executor pushed program words verbatim ahead of
                // every guard, which is exactly what a literal slot does.
                admits_leading_dash: true,
            })
            .collect();
        slots.extend(args);

        let parameters = translate_parameters(parameters, &mut untranslated);
        let translated_env = translate_env(env, hardened_path, &mut untranslated);

        let refusal = untranslated.first;

        DeclaredCommandContract {
            name,
            parameters,
            lines: vec![CommandLineContract { slots }],
            env: translated_env,
            cwd,
            prior_form_refusal: refusal,
        }
    }
}

/// Translate the prior parameter list, naming what cannot come across.
fn translate_parameters(
    parameters: Vec<PriorParameter>,
    untranslated: &mut Untranslated<'_>,
) -> Vec<CommandParameterContract> {
    parameters
        .into_iter()
        .map(|parameter| {
            // A parameter that took several values has no current rendering at
            // all: the prior executor turned one such value into one word per
            // item, and a fill is exactly one word now.
            if parameter.list {
                untranslated.record(
                    format!("the list parameter `{}`", parameter.name),
                    "under the old spelling this parameter took several values at once and each \
                     became its own word on the command line; a value now becomes exactly one \
                     word, so there is no command line left to build",
                );
            }
            // A literal default carries over as written. A default built out of
            // other parameters has no current spelling — a default is plain
            // text now — and reading it as "no default" would be a false
            // sentence about the archive.
            let default = match parameter.default.as_ref().map(literal_text) {
                None => None,
                Some(Some(literal)) => Some(literal),
                Some(None) => {
                    untranslated.record(
                        format!("the default of parameter `{}`", parameter.name),
                        "under the old spelling this parameter's default was built from the \
                         values of other parameters; a default is plain text now, so there is no \
                         value left for it to fall back on",
                    );
                    None
                }
            };
            CommandParameterContract {
                name: parameter.name,
                default,
            }
        })
        .collect()
}

/// Translate the prior environment bindings and the prior `hardening path`,
/// naming what cannot come across.
fn translate_env(
    env: Vec<PriorEnv>,
    hardened_path: Option<FillTemplate>,
    untranslated: &mut Untranslated<'_>,
) -> Vec<EnvBindingContract> {
    let mut translated: Vec<EnvBindingContract> = Vec::new();
    for binding in env {
        match literal_text(&binding.value) {
            Some(value) => translated.push(EnvBindingContract {
                name: binding.name,
                value,
            }),
            None => untranslated.record(
                format!(
                    "an environment binding for `{}` whose value interpolates a parameter",
                    binding.name
                ),
                "under the old spelling this variable's value was built from the values passed \
                 to the command at run time; an exported value is plain document text now, with \
                 no parameters in scope",
            ),
        }
    }
    if let Some(path) = hardened_path {
        // `hardening path` replaced the child's PATH with the declared value;
        // a literal PATH binding does the same under the current executor,
        // which clears the environment and honours declared bindings over its
        // own PATH.
        match literal_text(&path) {
            Some(value) => translated.push(EnvBindingContract {
                name: "PATH".to_owned(),
                value,
            }),
            None => untranslated.record(
                "an environment binding for `PATH` whose value interpolates a parameter".to_owned(),
                "under the old spelling this command replaced the executable search path with a \
                 value built from the values passed to it at run time; an exported value is \
                 plain document text now, with no parameters in scope",
            ),
        }
    }
    translated
}

/// The prior-form constructs one command carries that the current form cannot
/// express.
struct Untranslated<'a> {
    /// The command being read, for the operator's warning.
    command: &'a str,
    /// The FIRST construct found — one construct is enough to name the cure,
    /// and the redeploy that cures one cures them all.
    first: Option<String>,
}

impl Untranslated<'_> {
    /// Record one untranslatable construct and tell the operator at OPEN, not
    /// first at dispatch: a workflow that ran under the prior binary and would
    /// refuse at three in the morning on a schedule is a fact the operator
    /// hears at upgrade time. Every occurrence is named here; the marker keeps
    /// the first, and rides every serialized rendering of this contract, so
    /// any surface that shows the contract shows the state.
    ///
    /// `meant` says, in the operator's own terms, what the construct did under
    /// the prior form — the sentence that turns "this no longer works" into
    /// "here is what it used to do and why it cannot now".
    fn record(&mut self, construct: String, meant: &str) {
        tracing::warn!(
            operation = "prior_form_command_read",
            command = %self.command,
            construct = %construct,
            "this declared command was deployed before command declarations changed shape, and \
             it carries {construct}: {meant}. The archive still opens and lists everywhere, but \
             running this command will refuse until you redeploy the document under the current \
             spelling"
        );
        if self.first.is_none() {
            self.first = Some(construct);
        }
    }
}

/// The template's literal text, when it interpolates nothing.
fn literal_text(template: &FillTemplate) -> Option<String> {
    let mut text = String::new();
    for piece in &template.pieces {
        match piece {
            FillPiece::Literal { text: literal } => text.push_str(literal),
            FillPiece::Hole { .. } => return None,
        }
    }
    Some(text)
}