Skip to main content

canic_host/fleet_catalog/
mod.rs

1//! Module: fleet_catalog
2//!
3//! Responsibility: commit, read and project the canonical network-scoped Fleet catalog.
4//! Does not own: activation recovery, Fleet ID generation, or Canister activation.
5//! Boundary: one network lock serializes atomic catalog replacement; readers fail closed.
6
7#[cfg(test)]
8mod tests;
9
10use crate::{
11    durable_io::{
12        RegularFileLockError, RegularFileReadError, lock_regular_file_with_parents,
13        read_optional_regular_bytes, write_bytes,
14    },
15    network::{
16        NetworkIdentityError, resolve_canonical_network_id_from_root, validate_environment_name,
17    },
18};
19use canic_core::{
20    cdk::types::Principal,
21    ids::{AppId, CanonicalNetworkId, FleetId, FleetName, FleetNameParseError},
22};
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25use std::{
26    collections::{BTreeMap, BTreeSet},
27    io,
28    path::{Path, PathBuf},
29};
30use thiserror::Error as ThisError;
31
32const FLEET_CATALOG_SCHEMA_VERSION: u32 = 1;
33const FLEET_CATALOG_RELATIVE_PATH: &str = "fleets/catalog.json";
34const CANONICAL_NAME_MAX_BYTES: usize = 40;
35
36///
37/// FleetCatalogRequest
38///
39
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct FleetCatalogRequest {
42    pub project_root: PathBuf,
43    pub environment: String,
44    pub generated_at: String,
45}
46
47///
48/// FleetCatalogReportV1
49///
50
51#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
52#[serde(deny_unknown_fields)]
53pub struct FleetCatalogReportV1 {
54    pub schema_version: u32,
55    pub generated_at: String,
56    pub project_root: Option<String>,
57    pub canonical_network_id: CanonicalNetworkId,
58    pub environment: String,
59    pub entries: Vec<FleetCatalogEntryV1>,
60}
61
62///
63/// FleetCatalogEntryV1
64///
65
66#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
67#[serde(deny_unknown_fields)]
68pub struct FleetCatalogEntryV1 {
69    pub canonical_network_id: CanonicalNetworkId,
70    pub fleet_id: FleetId,
71    pub fleet_name: FleetName,
72    pub app: AppId,
73    /// Non-authoritative environment-profile provenance from installation.
74    pub environment: String,
75    pub deployed_at_unix_secs: u64,
76    pub root_principal: String,
77}
78
79#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
80#[serde(deny_unknown_fields)]
81struct FleetCatalogRecord {
82    schema_version: u32,
83    canonical_network_id: CanonicalNetworkId,
84    entries: Vec<FleetCatalogEntryV1>,
85}
86
87///
88/// CommittedFleetCatalog
89///
90
91#[derive(Clone, Debug, Eq, PartialEq)]
92pub(crate) struct CommittedFleetCatalog {
93    entry: FleetCatalogEntryV1,
94    catalog_hash: [u8; 32],
95    path: PathBuf,
96    advanced: bool,
97}
98
99impl CommittedFleetCatalog {
100    pub(crate) const fn entry(&self) -> &FleetCatalogEntryV1 {
101        &self.entry
102    }
103
104    pub(crate) const fn catalog_hash(&self) -> [u8; 32] {
105        self.catalog_hash
106    }
107
108    pub(crate) fn belongs_to(&self, project_root: &Path) -> bool {
109        self.path == fleet_catalog_path(project_root, self.entry.canonical_network_id)
110    }
111}
112
113///
114/// FleetCatalogError
115///
116
117#[derive(Debug, ThisError)]
118pub enum FleetCatalogError {
119    #[error(transparent)]
120    Network(#[from] NetworkIdentityError),
121
122    #[error("Fleet name is invalid: {0}")]
123    FleetName(#[from] FleetNameParseError),
124
125    #[error("Fleet {fleet_name} is not known on canonical network {canonical_network_id}")]
126    UnknownFleet {
127        canonical_network_id: CanonicalNetworkId,
128        fleet_name: FleetName,
129    },
130
131    #[error("Fleet catalog is not a regular non-symlink file: {}", path.display())]
132    NotRegular { path: PathBuf },
133
134    #[error("Fleet catalog is unsupported on platform {0}")]
135    UnsupportedPlatform(&'static str),
136
137    #[error("failed to read Fleet catalog {}: {source}", path.display())]
138    Read {
139        path: PathBuf,
140        #[source]
141        source: io::Error,
142    },
143
144    #[error("failed to commit Fleet catalog {}: {source}", path.display())]
145    Write {
146        path: PathBuf,
147        #[source]
148        source: io::Error,
149    },
150
151    #[error("Fleet catalog commitment conflicts with existing {field} authority: {value}")]
152    Conflict { field: &'static str, value: String },
153
154    #[error("failed to decode Fleet catalog {}: {source}", path.display())]
155    Decode {
156        path: PathBuf,
157        #[source]
158        source: serde_json::Error,
159    },
160
161    #[error("failed to encode Fleet catalog: {source}")]
162    Encode {
163        #[source]
164        source: serde_json::Error,
165    },
166
167    #[error("invalid Fleet catalog {}: {reason}", path.display())]
168    Invalid { path: PathBuf, reason: String },
169}
170
171/// Build a read-only report from the one catalog selected by canonical network identity.
172pub fn build_fleet_catalog_report(
173    request: &FleetCatalogRequest,
174) -> Result<FleetCatalogReportV1, FleetCatalogError> {
175    validate_environment_name(&request.environment)?;
176    let canonical_network_id =
177        resolve_canonical_network_id_from_root(&request.project_root, &request.environment)?;
178    let path = fleet_catalog_path(&request.project_root, canonical_network_id);
179    let entries = match read_catalog(&path, canonical_network_id)? {
180        Some(catalog) => catalog.entries,
181        None => Vec::new(),
182    };
183
184    Ok(FleetCatalogReportV1 {
185        schema_version: FLEET_CATALOG_SCHEMA_VERSION,
186        generated_at: request.generated_at.clone(),
187        project_root: Some(".".to_string()),
188        canonical_network_id,
189        environment: request.environment.clone(),
190        entries,
191    })
192}
193
194/// Build a report containing one exact Fleet-name lookup.
195pub fn inspect_fleet_catalog_report(
196    request: &FleetCatalogRequest,
197    fleet_name: &str,
198) -> Result<FleetCatalogReportV1, FleetCatalogError> {
199    let mut report = build_fleet_catalog_report(request)?;
200    let entry = require_fleet_catalog_entry(&report, fleet_name)?;
201    report.entries = vec![entry];
202    Ok(report)
203}
204
205/// Resolve one exact installed Fleet from the catalog selected by canonical
206/// network identity.
207pub fn read_fleet_catalog_entry_from_root(
208    project_root: &Path,
209    environment: &str,
210    fleet_name: &str,
211) -> Result<Option<FleetCatalogEntryV1>, FleetCatalogError> {
212    let fleet_name = fleet_name.parse::<FleetName>()?;
213    let report = build_fleet_catalog_report(&FleetCatalogRequest {
214        project_root: project_root.to_path_buf(),
215        environment: environment.to_string(),
216        generated_at: String::new(),
217    })?;
218    Ok(report
219        .entries
220        .into_iter()
221        .find(|entry| entry.fleet_name == fleet_name))
222}
223
224/// Commit one exact Fleet row through the canonical network catalog writer.
225pub(crate) fn commit_fleet_catalog_entry(
226    project_root: &Path,
227    entry: FleetCatalogEntryV1,
228) -> Result<CommittedFleetCatalog, FleetCatalogError> {
229    let path = fleet_catalog_path(project_root, entry.canonical_network_id);
230    let lock_path = fleet_catalog_lock_path(project_root, entry.canonical_network_id);
231    let _lock = lock_regular_file_with_parents(&lock_path).map_err(|error| match error {
232        RegularFileLockError::NotRegular => FleetCatalogError::NotRegular {
233            path: lock_path.clone(),
234        },
235        RegularFileLockError::Io(source) => FleetCatalogError::Write {
236            path: lock_path.clone(),
237            source,
238        },
239        #[cfg(windows)]
240        RegularFileLockError::UnsupportedPlatform => {
241            FleetCatalogError::UnsupportedPlatform(std::env::consts::OS)
242        }
243    })?;
244
245    let existing = read_catalog_document(&path, entry.canonical_network_id)?;
246    let mut catalog = existing.as_ref().map_or_else(
247        || FleetCatalogRecord {
248            schema_version: FLEET_CATALOG_SCHEMA_VERSION,
249            canonical_network_id: entry.canonical_network_id,
250            entries: Vec::new(),
251        },
252        |document| document.record.clone(),
253    );
254    if let Some(existing_entry) = existing_authority(&catalog.entries, &entry)? {
255        let bytes = existing
256            .expect("existing authority came from an existing catalog")
257            .bytes;
258        return Ok(CommittedFleetCatalog {
259            entry: existing_entry.clone(),
260            catalog_hash: Sha256::digest(&bytes).into(),
261            path,
262            advanced: false,
263        });
264    }
265
266    catalog.entries.push(entry.clone());
267    catalog
268        .entries
269        .sort_by(|left, right| left.fleet_name.cmp(&right.fleet_name));
270    validate_catalog(&path, &catalog, entry.canonical_network_id)?;
271    let bytes = canonical_catalog_bytes(&catalog)?;
272    write_bytes(&path, &bytes).map_err(|source| FleetCatalogError::Write {
273        path: path.clone(),
274        source,
275    })?;
276    let durable = read_catalog_document(&path, entry.canonical_network_id)?.ok_or_else(|| {
277        FleetCatalogError::Invalid {
278            path: path.clone(),
279            reason: "committed Fleet catalog is missing after publication".to_string(),
280        }
281    })?;
282    if durable.record != catalog || durable.bytes != bytes {
283        return invalid(
284            &path,
285            "committed Fleet catalog differs from the exact publication bytes".to_string(),
286        );
287    }
288    Ok(CommittedFleetCatalog {
289        entry,
290        catalog_hash: Sha256::digest(&bytes).into(),
291        path,
292        advanced: true,
293    })
294}
295
296/// Read one exact Fleet-name row under an already resolved network identity.
297pub(crate) fn read_fleet_catalog_entry_for_network(
298    project_root: &Path,
299    canonical_network_id: CanonicalNetworkId,
300    fleet_name: &FleetName,
301) -> Result<Option<FleetCatalogEntryV1>, FleetCatalogError> {
302    let path = fleet_catalog_path(project_root, canonical_network_id);
303    Ok(
304        read_catalog(&path, canonical_network_id)?.and_then(|catalog| {
305            catalog
306                .entries
307                .into_iter()
308                .find(|entry| entry.fleet_name == *fleet_name)
309        }),
310    )
311}
312
313fn require_fleet_catalog_entry(
314    report: &FleetCatalogReportV1,
315    fleet_name: &str,
316) -> Result<FleetCatalogEntryV1, FleetCatalogError> {
317    let fleet_name = fleet_name.parse::<FleetName>()?;
318    report
319        .entries
320        .iter()
321        .find(|entry| entry.fleet_name == fleet_name)
322        .cloned()
323        .ok_or(FleetCatalogError::UnknownFleet {
324            canonical_network_id: report.canonical_network_id,
325            fleet_name,
326        })
327}
328
329#[must_use]
330pub fn fleet_catalog_report_text(report: &FleetCatalogReportV1) -> String {
331    let mut lines = vec![
332        "Fleet catalog:".to_string(),
333        format!("generated_at: {}", report.generated_at),
334        format!("network: {}", report.canonical_network_id),
335        format!("environment: {}", report.environment),
336        format!("entries: {}", report.entries.len()),
337    ];
338    if let Some(project_root) = &report.project_root {
339        lines.push(format!("project_root: {project_root}"));
340    }
341    if report.entries.is_empty() {
342        lines.push("fleets: none".to_string());
343        return lines.join("\n");
344    }
345
346    lines.push("fleets:".to_string());
347    for entry in &report.entries {
348        lines.push(format!("  {}", entry.fleet_name));
349        lines.push(format!("    fleet_id: {}", entry.fleet_id));
350        lines.push(format!("    app: {}", entry.app));
351        lines.push(format!("    environment: {}", entry.environment));
352        lines.push(format!("    root_principal: {}", entry.root_principal));
353    }
354    lines.join("\n")
355}
356
357fn read_catalog(
358    path: &Path,
359    canonical_network_id: CanonicalNetworkId,
360) -> Result<Option<FleetCatalogRecord>, FleetCatalogError> {
361    Ok(read_catalog_document(path, canonical_network_id)?.map(|document| document.record))
362}
363
364struct FleetCatalogDocument {
365    record: FleetCatalogRecord,
366    bytes: Vec<u8>,
367}
368
369fn read_catalog_document(
370    path: &Path,
371    canonical_network_id: CanonicalNetworkId,
372) -> Result<Option<FleetCatalogDocument>, FleetCatalogError> {
373    let Some(bytes) = read_optional_regular_bytes(path).map_err(|error| match error {
374        RegularFileReadError::NotRegular => FleetCatalogError::NotRegular {
375            path: path.to_path_buf(),
376        },
377        RegularFileReadError::Io(source) => FleetCatalogError::Read {
378            path: path.to_path_buf(),
379            source,
380        },
381        #[cfg(not(unix))]
382        RegularFileReadError::UnsupportedPlatform => {
383            FleetCatalogError::UnsupportedPlatform(std::env::consts::OS)
384        }
385    })?
386    else {
387        return Ok(None);
388    };
389    let catalog = serde_json::from_slice::<FleetCatalogRecord>(&bytes).map_err(|source| {
390        FleetCatalogError::Decode {
391            path: path.to_path_buf(),
392            source,
393        }
394    })?;
395    validate_catalog(path, &catalog, canonical_network_id)?;
396    Ok(Some(FleetCatalogDocument {
397        record: catalog,
398        bytes,
399    }))
400}
401
402fn validate_catalog(
403    path: &Path,
404    catalog: &FleetCatalogRecord,
405    canonical_network_id: CanonicalNetworkId,
406) -> Result<(), FleetCatalogError> {
407    if catalog.schema_version != FLEET_CATALOG_SCHEMA_VERSION {
408        return invalid(
409            path,
410            format!(
411                "schema version {} is not supported; expected {}",
412                catalog.schema_version, FLEET_CATALOG_SCHEMA_VERSION
413            ),
414        );
415    }
416    if catalog.canonical_network_id != canonical_network_id {
417        return invalid(
418            path,
419            format!(
420                "catalog network {} does not match resolved network {canonical_network_id}",
421                catalog.canonical_network_id
422            ),
423        );
424    }
425
426    let mut previous_name: Option<&FleetName> = None;
427    let mut fleet_ids = BTreeSet::new();
428    let mut root_principals = BTreeMap::new();
429    for entry in &catalog.entries {
430        if entry.canonical_network_id != canonical_network_id {
431            return invalid(
432                path,
433                format!(
434                    "Fleet {} records network {}, not {canonical_network_id}",
435                    entry.fleet_name, entry.canonical_network_id
436                ),
437            );
438        }
439        if previous_name.is_some_and(|previous| previous >= &entry.fleet_name) {
440            return invalid(
441                path,
442                "Fleet entries must be strictly ordered by fleet_name".to_string(),
443            );
444        }
445        if !fleet_ids.insert(entry.fleet_id) {
446            return invalid(
447                path,
448                format!("Fleet ID {} appears more than once", entry.fleet_id),
449            );
450        }
451        validate_canonical_name(entry.app.as_str()).map_err(|reason| {
452            FleetCatalogError::Invalid {
453                path: path.to_path_buf(),
454                reason: format!("App {} {reason}", entry.app),
455            }
456        })?;
457        validate_environment_name(&entry.environment)?;
458        let root_principal = Principal::from_text(&entry.root_principal).map_err(|error| {
459            FleetCatalogError::Invalid {
460                path: path.to_path_buf(),
461                reason: format!(
462                    "Fleet {} has invalid root principal: {error}",
463                    entry.fleet_name
464                ),
465            }
466        })?;
467        if let Some(first) = root_principals.insert(root_principal, &entry.fleet_name) {
468            return invalid(
469                path,
470                format!(
471                    "root principal {} belongs to both Fleet {first} and {}",
472                    entry.root_principal, entry.fleet_name
473                ),
474            );
475        }
476        previous_name = Some(&entry.fleet_name);
477    }
478    Ok(())
479}
480
481fn existing_authority<'a>(
482    entries: &'a [FleetCatalogEntryV1],
483    requested: &FleetCatalogEntryV1,
484) -> Result<Option<&'a FleetCatalogEntryV1>, FleetCatalogError> {
485    let by_name = entries
486        .iter()
487        .find(|entry| entry.fleet_name == requested.fleet_name);
488    let by_id = entries
489        .iter()
490        .find(|entry| entry.fleet_id == requested.fleet_id);
491    let by_root = entries
492        .iter()
493        .find(|entry| entry.root_principal == requested.root_principal);
494    for (field, value, existing) in [
495        ("fleet_name", requested.fleet_name.to_string(), by_name),
496        ("fleet_id", requested.fleet_id.to_string(), by_id),
497        ("root_principal", requested.root_principal.clone(), by_root),
498    ] {
499        if let Some(existing) = existing {
500            if same_fleet_authority(existing, requested) {
501                return Ok(Some(existing));
502            }
503            return Err(FleetCatalogError::Conflict { field, value });
504        }
505    }
506    Ok(None)
507}
508
509fn same_fleet_authority(existing: &FleetCatalogEntryV1, requested: &FleetCatalogEntryV1) -> bool {
510    existing.canonical_network_id == requested.canonical_network_id
511        && existing.fleet_id == requested.fleet_id
512        && existing.fleet_name == requested.fleet_name
513        && existing.app == requested.app
514        && existing.root_principal == requested.root_principal
515}
516
517fn canonical_catalog_bytes(catalog: &FleetCatalogRecord) -> Result<Vec<u8>, FleetCatalogError> {
518    let mut bytes = serde_json::to_vec_pretty(catalog)
519        .map_err(|source| FleetCatalogError::Encode { source })?;
520    bytes.push(b'\n');
521    Ok(bytes)
522}
523
524fn validate_canonical_name(value: &str) -> Result<(), String> {
525    if value.is_empty() {
526        return Err("must not be empty".to_string());
527    }
528    if value.len() > CANONICAL_NAME_MAX_BYTES {
529        return Err(format!("must not exceed {CANONICAL_NAME_MAX_BYTES} bytes"));
530    }
531    if !value
532        .bytes()
533        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
534    {
535        return Err("must use only ASCII letters, numbers, '-' or '_'".to_string());
536    }
537    Ok(())
538}
539
540fn invalid<T>(path: &Path, reason: String) -> Result<T, FleetCatalogError> {
541    Err(FleetCatalogError::Invalid {
542        path: path.to_path_buf(),
543        reason,
544    })
545}
546
547fn fleet_catalog_path(project_root: &Path, canonical_network_id: CanonicalNetworkId) -> PathBuf {
548    project_root
549        .join(".canic")
550        .join("networks")
551        .join(canonical_network_id.to_string())
552        .join(FLEET_CATALOG_RELATIVE_PATH)
553}
554
555fn fleet_catalog_lock_path(
556    project_root: &Path,
557    canonical_network_id: CanonicalNetworkId,
558) -> PathBuf {
559    project_root
560        .join(".canic")
561        .join("networks")
562        .join(canonical_network_id.to_string())
563        .join("fleets/catalog.lock")
564}