Skip to main content

canic_host/fleet_catalog/
mod.rs

1//! Module: fleet_catalog
2//!
3//! Responsibility: read and project the friendly network-scoped Fleet catalog.
4//! Does not own: activation recovery, Fleet ID generation, or catalog commitment.
5//! Boundary: the resolved canonical network selects one exact fail-closed catalog.
6
7#[cfg(test)]
8mod tests;
9
10use crate::{
11    durable_io::{RegularFileReadError, read_optional_regular_bytes},
12    network::{
13        NetworkIdentityError, resolve_canonical_network_id_from_root, validate_environment_name,
14    },
15};
16use canic_core::{
17    cdk::types::Principal,
18    ids::{AppId, CanonicalNetworkId, FleetId, FleetName, FleetNameParseError},
19};
20use serde::{Deserialize, Serialize};
21use std::{
22    collections::BTreeSet,
23    io,
24    path::{Path, PathBuf},
25};
26use thiserror::Error as ThisError;
27
28const FLEET_CATALOG_SCHEMA_VERSION: u32 = 1;
29const FLEET_CATALOG_RELATIVE_PATH: &str = "fleets/catalog.json";
30const CANONICAL_NAME_MAX_BYTES: usize = 40;
31
32///
33/// FleetCatalogRequest
34///
35
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct FleetCatalogRequest {
38    pub project_root: PathBuf,
39    pub environment: String,
40    pub generated_at: String,
41}
42
43///
44/// FleetCatalogReportV1
45///
46
47#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
48#[serde(deny_unknown_fields)]
49pub struct FleetCatalogReportV1 {
50    pub schema_version: u32,
51    pub generated_at: String,
52    pub project_root: Option<String>,
53    pub canonical_network_id: CanonicalNetworkId,
54    pub environment: String,
55    pub entries: Vec<FleetCatalogEntryV1>,
56}
57
58///
59/// FleetCatalogEntryV1
60///
61
62#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
63#[serde(deny_unknown_fields)]
64pub struct FleetCatalogEntryV1 {
65    pub canonical_network_id: CanonicalNetworkId,
66    pub fleet_id: FleetId,
67    pub fleet_name: FleetName,
68    pub app: AppId,
69    /// Non-authoritative environment-profile provenance from installation.
70    pub environment: String,
71    pub deployed_at_unix_secs: u64,
72    pub root_principal: String,
73    pub root_verification: FleetCatalogRootVerificationV1,
74}
75
76///
77/// FleetCatalogRootVerificationV1
78///
79
80#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
81pub enum FleetCatalogRootVerificationV1 {
82    #[serde(rename = "not_verified")]
83    NotVerified,
84    #[serde(rename = "verified")]
85    Verified,
86}
87
88#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
89#[serde(deny_unknown_fields)]
90struct FleetCatalogRecord {
91    schema_version: u32,
92    canonical_network_id: CanonicalNetworkId,
93    entries: Vec<FleetCatalogEntryV1>,
94}
95
96///
97/// FleetCatalogError
98///
99
100#[derive(Debug, ThisError)]
101pub enum FleetCatalogError {
102    #[error(transparent)]
103    Network(#[from] NetworkIdentityError),
104
105    #[error("Fleet name is invalid: {0}")]
106    FleetName(#[from] FleetNameParseError),
107
108    #[error("Fleet {fleet_name} is not known on canonical network {canonical_network_id}")]
109    UnknownFleet {
110        canonical_network_id: CanonicalNetworkId,
111        fleet_name: FleetName,
112    },
113
114    #[error("Fleet catalog is not a regular non-symlink file: {}", path.display())]
115    NotRegular { path: PathBuf },
116
117    #[error("Fleet catalog is unsupported on platform {0}")]
118    UnsupportedPlatform(&'static str),
119
120    #[error("failed to read Fleet catalog {}: {source}", path.display())]
121    Read {
122        path: PathBuf,
123        #[source]
124        source: io::Error,
125    },
126
127    #[error("failed to decode Fleet catalog {}: {source}", path.display())]
128    Decode {
129        path: PathBuf,
130        #[source]
131        source: serde_json::Error,
132    },
133
134    #[error("invalid Fleet catalog {}: {reason}", path.display())]
135    Invalid { path: PathBuf, reason: String },
136}
137
138/// Build a read-only report from the one catalog selected by canonical network identity.
139pub fn build_fleet_catalog_report(
140    request: &FleetCatalogRequest,
141) -> Result<FleetCatalogReportV1, FleetCatalogError> {
142    validate_environment_name(&request.environment)?;
143    let canonical_network_id =
144        resolve_canonical_network_id_from_root(&request.project_root, &request.environment)?;
145    let path = fleet_catalog_path(&request.project_root, canonical_network_id);
146    let entries = match read_catalog(&path, canonical_network_id)? {
147        Some(catalog) => catalog.entries,
148        None => Vec::new(),
149    };
150
151    Ok(FleetCatalogReportV1 {
152        schema_version: FLEET_CATALOG_SCHEMA_VERSION,
153        generated_at: request.generated_at.clone(),
154        project_root: Some(".".to_string()),
155        canonical_network_id,
156        environment: request.environment.clone(),
157        entries,
158    })
159}
160
161/// Build a report containing one exact Fleet-name lookup.
162pub fn inspect_fleet_catalog_report(
163    request: &FleetCatalogRequest,
164    fleet_name: &str,
165) -> Result<FleetCatalogReportV1, FleetCatalogError> {
166    let fleet_name = fleet_name.parse::<FleetName>()?;
167    let mut report = build_fleet_catalog_report(request)?;
168    let entry = report
169        .entries
170        .iter()
171        .find(|entry| entry.fleet_name == fleet_name)
172        .cloned()
173        .ok_or(FleetCatalogError::UnknownFleet {
174            canonical_network_id: report.canonical_network_id,
175            fleet_name,
176        })?;
177    report.entries = vec![entry];
178    Ok(report)
179}
180
181#[must_use]
182pub fn fleet_catalog_report_text(report: &FleetCatalogReportV1) -> String {
183    let mut lines = vec![
184        "Fleet catalog:".to_string(),
185        format!("generated_at: {}", report.generated_at),
186        format!("network: {}", report.canonical_network_id),
187        format!("environment: {}", report.environment),
188        format!("entries: {}", report.entries.len()),
189    ];
190    if let Some(project_root) = &report.project_root {
191        lines.push(format!("project_root: {project_root}"));
192    }
193    if report.entries.is_empty() {
194        lines.push("fleets: none".to_string());
195        return lines.join("\n");
196    }
197
198    lines.push("fleets:".to_string());
199    for entry in &report.entries {
200        lines.push(format!("  {}", entry.fleet_name));
201        lines.push(format!("    fleet_id: {}", entry.fleet_id));
202        lines.push(format!("    app: {}", entry.app));
203        lines.push(format!("    environment: {}", entry.environment));
204        lines.push(format!("    root_principal: {}", entry.root_principal));
205        lines.push(format!(
206            "    root_verification: {}",
207            root_verification_label(entry.root_verification)
208        ));
209    }
210    lines.join("\n")
211}
212
213fn read_catalog(
214    path: &Path,
215    canonical_network_id: CanonicalNetworkId,
216) -> Result<Option<FleetCatalogRecord>, FleetCatalogError> {
217    let Some(bytes) = read_optional_regular_bytes(path).map_err(|error| match error {
218        RegularFileReadError::NotRegular => FleetCatalogError::NotRegular {
219            path: path.to_path_buf(),
220        },
221        RegularFileReadError::Io(source) => FleetCatalogError::Read {
222            path: path.to_path_buf(),
223            source,
224        },
225        #[cfg(not(unix))]
226        RegularFileReadError::UnsupportedPlatform => {
227            FleetCatalogError::UnsupportedPlatform(std::env::consts::OS)
228        }
229    })?
230    else {
231        return Ok(None);
232    };
233    let catalog = serde_json::from_slice::<FleetCatalogRecord>(&bytes).map_err(|source| {
234        FleetCatalogError::Decode {
235            path: path.to_path_buf(),
236            source,
237        }
238    })?;
239    validate_catalog(path, &catalog, canonical_network_id)?;
240    Ok(Some(catalog))
241}
242
243fn validate_catalog(
244    path: &Path,
245    catalog: &FleetCatalogRecord,
246    canonical_network_id: CanonicalNetworkId,
247) -> Result<(), FleetCatalogError> {
248    if catalog.schema_version != FLEET_CATALOG_SCHEMA_VERSION {
249        return invalid(
250            path,
251            format!(
252                "schema version {} is not supported; expected {}",
253                catalog.schema_version, FLEET_CATALOG_SCHEMA_VERSION
254            ),
255        );
256    }
257    if catalog.canonical_network_id != canonical_network_id {
258        return invalid(
259            path,
260            format!(
261                "catalog network {} does not match resolved network {canonical_network_id}",
262                catalog.canonical_network_id
263            ),
264        );
265    }
266
267    let mut previous_name: Option<&FleetName> = None;
268    let mut fleet_ids = BTreeSet::new();
269    for entry in &catalog.entries {
270        if entry.canonical_network_id != canonical_network_id {
271            return invalid(
272                path,
273                format!(
274                    "Fleet {} records network {}, not {canonical_network_id}",
275                    entry.fleet_name, entry.canonical_network_id
276                ),
277            );
278        }
279        if previous_name.is_some_and(|previous| previous >= &entry.fleet_name) {
280            return invalid(
281                path,
282                "Fleet entries must be strictly ordered by fleet_name".to_string(),
283            );
284        }
285        if !fleet_ids.insert(entry.fleet_id) {
286            return invalid(
287                path,
288                format!("Fleet ID {} appears more than once", entry.fleet_id),
289            );
290        }
291        validate_canonical_name(entry.app.as_str()).map_err(|reason| {
292            FleetCatalogError::Invalid {
293                path: path.to_path_buf(),
294                reason: format!("App {} {reason}", entry.app),
295            }
296        })?;
297        validate_environment_name(&entry.environment)?;
298        Principal::from_text(&entry.root_principal).map_err(|error| {
299            FleetCatalogError::Invalid {
300                path: path.to_path_buf(),
301                reason: format!(
302                    "Fleet {} has invalid root principal: {error}",
303                    entry.fleet_name
304                ),
305            }
306        })?;
307        previous_name = Some(&entry.fleet_name);
308    }
309    Ok(())
310}
311
312fn validate_canonical_name(value: &str) -> Result<(), String> {
313    if value.is_empty() {
314        return Err("must not be empty".to_string());
315    }
316    if value.len() > CANONICAL_NAME_MAX_BYTES {
317        return Err(format!("must not exceed {CANONICAL_NAME_MAX_BYTES} bytes"));
318    }
319    if !value
320        .bytes()
321        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
322    {
323        return Err("must use only ASCII letters, numbers, '-' or '_'".to_string());
324    }
325    Ok(())
326}
327
328fn invalid<T>(path: &Path, reason: String) -> Result<T, FleetCatalogError> {
329    Err(FleetCatalogError::Invalid {
330        path: path.to_path_buf(),
331        reason,
332    })
333}
334
335fn fleet_catalog_path(project_root: &Path, canonical_network_id: CanonicalNetworkId) -> PathBuf {
336    project_root
337        .join(".canic")
338        .join("networks")
339        .join(canonical_network_id.to_string())
340        .join(FLEET_CATALOG_RELATIVE_PATH)
341}
342
343const fn root_verification_label(status: FleetCatalogRootVerificationV1) -> &'static str {
344    match status {
345        FleetCatalogRootVerificationV1::NotVerified => "not_verified",
346        FleetCatalogRootVerificationV1::Verified => "verified",
347    }
348}