Skip to main content

aion_server/update_check/
document.rs

1//! The embedded update-check document: its bytes, its compiled identity, and
2//! the verified names the server half keys on.
3//!
4//! # One document, one place
5//!
6//! `crates/aion-server/update-check-embed/update-check.awl` is the ONLY copy
7//! of the update-check document in this repository, embedded exactly as the
8//! assistant's is (`include_str!` from inside the crate, so `cargo package`
9//! carries it and an installed binary holds the same bytes this repository
10//! does). The boot install, the dispatch observer, and this module's tests
11//! all read this one artifact.
12//!
13//! # The names are declared once and VERIFIED, never restated
14//!
15//! The dispatch observer ([`super::observer`]) recognises the check by its
16//! task queue and action name, and trusts a completed dispatch only when the
17//! run's resolved body is the exact command below. Those three names are
18//! declared here as constants and [`EmbeddedUpdateCheck::load`] proves each
19//! one against the compiled document before returning — a document edit that
20//! renames the queue, the action, or the command fails loudly at load (at
21//! boot, and in this module's own tests) instead of leaving the observer
22//! keyed on names the document no longer carries.
23
24use std::path::Path;
25use std::sync::OnceLock;
26
27use aion_awl::TypeBody;
28use aion_package::{
29    ActionBodyContract, ContentHash, ExtractionLimits, Package, PackageError, WorkerContract,
30};
31
32/// The embedded update-check document, compiled into the binary.
33pub const EMBEDDED_UPDATE_CHECK_DOCUMENT: &str =
34    include_str!("../../update-check-embed/update-check.awl");
35
36/// The document's own filename, recorded in the assembled archive's `awl/`
37/// provenance tree.
38pub const EMBEDDED_UPDATE_CHECK_FILENAME: &str = "update-check.awl";
39
40/// The workflow type an operator starts. Also the compiled entry module, which
41/// [`EmbeddedUpdateCheck::load`] verifies.
42///
43/// # The console DUPLICATES this name, and nothing pins the two together
44///
45/// `apps/aion-ops-console/src/features/update/lib/contract.ts` carries the
46/// same literal for its "check now" start, pinned on its side by
47/// `contract.test.ts`. No build step connects that literal to this constant —
48/// TypeScript cannot see Rust. **Renaming this workflow type therefore
49/// requires renaming it there in the same change**, or every console's check
50/// button starts a workflow type that no longer exists.
51pub const UPDATE_CHECK_WORKFLOW_TYPE: &str = "update_check";
52
53/// The task queue the check's one action is declared on. No worker serves it;
54/// the declared body executes at the server.
55pub const UPDATE_CHECK_QUEUE: &str = "update_check";
56
57/// The one action: fetch the sparse-index line file for `aion-cli`.
58pub const FETCH_ACTION: &str = "fetch_crate_index";
59
60/// The declared command, verbatim as the document authors it. The observer
61/// records a check's result ONLY when the dispatching run's resolved body is
62/// exactly this command — see [`super::observer`] for why. The crate the URL
63/// ends in is the crate the index parser demands of every line
64/// (`super::index::INDEX_CRATE_NAME`); a document test pins the two together.
65pub const FETCH_COMMAND: &str = "curl -fsS https://index.crates.io/ai/on/aion-cli";
66
67/// A refusal to produce the embedded update-check package, naming the stage
68/// that refused.
69#[derive(Debug, thiserror::Error)]
70pub enum EmbeddedUpdateCheckError {
71    /// The embedded document does not parse.
72    #[error("the embedded update-check document does not parse: {message}")]
73    Parse {
74        /// The parser's diagnostic, verbatim.
75        message: String,
76    },
77
78    /// The embedded document carries a `schema(…)` import, which the binary —
79    /// embedding one file and no directory — has nothing to resolve against.
80    #[error(
81        "the embedded update-check document imports schema `{path}`, but the binary embeds the \
82         document alone and has no directory to resolve imports against; declare the type \
83         inline in the document"
84    )]
85    SchemaImport {
86        /// The import path, verbatim from the document.
87        path: String,
88    },
89
90    /// The embedded document does not compile.
91    #[error("the embedded update-check document does not compile: {message}")]
92    Compile {
93        /// The compiler's diagnostic, verbatim.
94        message: String,
95    },
96
97    /// The compiled document could not be assembled into an archive.
98    #[error("the embedded update-check document could not be packaged: {message}")]
99    Assemble {
100        /// The assembler's diagnostic, verbatim.
101        message: String,
102    },
103
104    /// The assembled archive did not load back as a validated package.
105    #[error("the embedded update-check package did not validate: {source}")]
106    Package {
107        /// The package validation failure.
108        #[from]
109        source: PackageError,
110    },
111
112    /// The compiled entry module is not [`UPDATE_CHECK_WORKFLOW_TYPE`].
113    #[error(
114        "the embedded update-check document compiles to workflow type `{found}`, but the server \
115         half in crates/aion-server/src/update_check/document.rs names \
116         `{UPDATE_CHECK_WORKFLOW_TYPE}`, so document and constants have diverged"
117    )]
118    WrongWorkflowType {
119        /// The entry module the package actually carries.
120        found: String,
121    },
122
123    /// The compiled package declares no contract at all.
124    #[error("the embedded update-check package carries no readable contract: {message}")]
125    MissingContract {
126        /// Why the contract could not be read.
127        message: String,
128    },
129
130    /// The contract does not declare [`FETCH_ACTION`] on
131    /// [`UPDATE_CHECK_QUEUE`].
132    #[error(
133        "the embedded update-check contract declares no `{FETCH_ACTION}` action on queue \
134         `{UPDATE_CHECK_QUEUE}`; the server half names both, so document and constants have \
135         diverged"
136    )]
137    MissingAction,
138
139    /// The action's declared body is absent or is not [`FETCH_COMMAND`].
140    #[error(
141        "the embedded update-check action `{FETCH_ACTION}` declares body {found:?}, but the \
142         server half records results only from `{FETCH_COMMAND}`, so document and constants \
143         have diverged"
144    )]
145    WrongBody {
146        /// The body the document actually declares, or `None` when it
147        /// declares no body at all.
148        found: Option<String>,
149    },
150}
151
152/// The embedded update check: the document's bytes and the package compiled
153/// from them.
154///
155/// Construction is the verification: holding one of these is proof that the
156/// embedded document compiled, packaged, and carries the exact queue, action,
157/// and command the server half keys on.
158#[derive(Debug, Clone)]
159pub struct EmbeddedUpdateCheck {
160    source: String,
161    package: Package,
162    workflow_type: String,
163}
164
165impl EmbeddedUpdateCheck {
166    /// Compiles, packages, and verifies the embedded document.
167    ///
168    /// # Errors
169    ///
170    /// Returns [`EmbeddedUpdateCheckError`] naming the stage that refused.
171    pub fn load() -> Result<Self, EmbeddedUpdateCheckError> {
172        Self::from_source(EMBEDDED_UPDATE_CHECK_DOCUMENT)
173    }
174
175    /// The whole preparation, over an arbitrary document.
176    ///
177    /// [`Self::load`] is this applied to the embedded bytes; it is separate so
178    /// the verification can be exercised against documents that deliberately
179    /// omit a surface — a check nothing ever fails is a check nobody has
180    /// measured. Takes any `&str` (the source is retained owned) so test
181    /// documents need no `'static` lifetime and no leak.
182    ///
183    /// # Errors
184    ///
185    /// As [`Self::load`].
186    pub fn from_source(source: &str) -> Result<Self, EmbeddedUpdateCheckError> {
187        let document =
188            aion_awl::parse(source).map_err(|error| EmbeddedUpdateCheckError::Parse {
189                message: error.message,
190            })?;
191        for declaration in &document.types {
192            if let TypeBody::SchemaImport { path, .. } = &declaration.body {
193                return Err(EmbeddedUpdateCheckError::SchemaImport { path: path.clone() });
194            }
195        }
196
197        // Never read (the load refuses schema imports above); a path that does
198        // not exist so a future import fails loudly here rather than resolving
199        // against whatever directory the server runs in. Same doctrine as the
200        // assistant embed.
201        let root = Path::new("<embedded-update-check-has-no-schema-directory>");
202        let prepared = aion_awl_package::compile_and_assemble_awl(
203            source,
204            root,
205            EMBEDDED_UPDATE_CHECK_FILENAME,
206        )
207        .map_err(|error| match error {
208            aion_awl_package::PrepareAwlError::Compile(compile) => {
209                EmbeddedUpdateCheckError::Compile {
210                    message: compile.to_string(),
211                }
212            }
213            other => EmbeddedUpdateCheckError::Assemble {
214                message: other.to_string(),
215            },
216        })?;
217
218        // Trusted, compile-time content assembled by this process moments ago —
219        // not network input — so extraction carries no inflate ceiling.
220        let package = Package::load_from_bytes(&prepared.archive, ExtractionLimits::unbounded())?;
221        let workflow_type = package.manifest().entry_module.clone();
222        if workflow_type != UPDATE_CHECK_WORKFLOW_TYPE {
223            return Err(EmbeddedUpdateCheckError::WrongWorkflowType {
224                found: workflow_type,
225            });
226        }
227
228        let contract =
229            package
230                .contract()
231                .map_err(|error| EmbeddedUpdateCheckError::MissingContract {
232                    message: error.to_string(),
233                })?;
234        let declared_body = contract
235            .workers
236            .iter()
237            .filter(|worker: &&WorkerContract| worker.task_queue == UPDATE_CHECK_QUEUE)
238            .flat_map(|worker| worker.actions.iter())
239            .find(|action| action.name == FETCH_ACTION)
240            .ok_or(EmbeddedUpdateCheckError::MissingAction)?
241            .body
242            .clone();
243        match declared_body {
244            Some(ActionBodyContract::Run { ref command }) if command == FETCH_COMMAND => {}
245            Some(ActionBodyContract::Run { command }) => {
246                return Err(EmbeddedUpdateCheckError::WrongBody {
247                    found: Some(command),
248                });
249            }
250            // The embedded document declares a `run` body and only a `run`
251            // body. A declared-command body here would be a different program
252            // with a different argument list, so it is refused by name rather
253            // than accepted as "close enough".
254            Some(ActionBodyContract::Command { command, .. }) => {
255                return Err(EmbeddedUpdateCheckError::WrongBody {
256                    found: Some(format!("runs command {}", command.name)),
257                });
258            }
259            None => return Err(EmbeddedUpdateCheckError::WrongBody { found: None }),
260        }
261
262        Ok(Self {
263            source: source.to_owned(),
264            package,
265            workflow_type,
266        })
267    }
268
269    /// The validated package the engine loads.
270    #[must_use]
271    pub const fn package(&self) -> &Package {
272        &self.package
273    }
274
275    /// The workflow type an operator starts — [`UPDATE_CHECK_WORKFLOW_TYPE`],
276    /// by construction.
277    #[must_use]
278    pub fn workflow_type(&self) -> &str {
279        &self.workflow_type
280    }
281
282    /// The package's content hash — this document's version identity.
283    #[must_use]
284    pub const fn content_hash(&self) -> &ContentHash {
285        self.package.content_hash()
286    }
287
288    /// The document source, verbatim.
289    #[must_use]
290    pub fn source(&self) -> &str {
291        &self.source
292    }
293}
294
295/// The process-wide embedded update check, compiled once on first use.
296///
297/// # Errors
298///
299/// Returns the [`EmbeddedUpdateCheckError`] from the single load attempt.
300pub fn embedded_update_check()
301-> Result<&'static EmbeddedUpdateCheck, &'static EmbeddedUpdateCheckError> {
302    static EMBEDDED: OnceLock<Result<EmbeddedUpdateCheck, EmbeddedUpdateCheckError>> =
303        OnceLock::new();
304    EMBEDDED.get_or_init(EmbeddedUpdateCheck::load).as_ref()
305}
306
307#[cfg(test)]
308#[path = "document_tests.rs"]
309mod document_tests;