mod bundle;
mod capacity;
mod uploaded;
use crate::{
fleet_ensure::{policy::expected_plan_sha256, resolve_current_fleet},
frontend::{
FrontendError,
model::{
FrontendEnvironmentInput, FrontendFileRecord, FrontendManifestRecord,
FrontendRoleRecord,
},
view::{FrontendAuthorityView, FrontendBindingsView, FrontendBundleView},
},
network::{frontend_root_key, resolve_canonical_network_id_from_root},
protocol_binding::resolve_registry_protocol_binding,
};
use ic_host_fs::read::read_file_no_follow;
use candid::TypeEnv;
use candid_parser::{
IDLProg,
bindings::{javascript, typescript},
syntax::{Dec, IDLMergedProg},
};
use canic_core::cdk::utils::hash::{hex_bytes, sha256_hex};
use std::{collections::BTreeMap, path::Path};
pub use bundle::{manifest_digest, publish_bundle, verify_bundle};
pub use capacity::{asset_capacity, payload_inventory};
pub use uploaded::{FrontendAssetReader, IcpFrontendAssetReader, prepare_uploaded};
pub const MAX_FRONTEND_FILE_BYTES: usize = 4 * 1024 * 1024;
pub const MAX_FRONTEND_BUNDLE_BYTES: usize = 32 * 1024 * 1024;
pub fn read_input(path: &Path) -> Result<FrontendEnvironmentInput, FrontendError> {
Ok(serde_json::from_slice(
&read_file_no_follow(path, MAX_FRONTEND_FILE_BYTES).map_err(std::io::Error::from)?,
)?)
}
pub fn authority(
root: &Path,
environment: &str,
fleet: &str,
) -> Result<FrontendAuthorityView, FrontendError> {
let current = resolve_current_fleet(root, environment, fleet)?;
if expected_plan_sha256(¤t.plan) != current.plan.plan_sha256 {
return Err(FrontendError::Integrity);
}
let registry = current.initial_active_registry(fleet)?;
if registry.fleet_subnet_roots.iter().any(|root| {
!matches!(
root.status,
canic_core::dto::fleet_registry::FleetSubnetRootStatus::Active
| canic_core::dto::fleet_registry::FleetSubnetRootStatus::Removed
)
}) {
return Err(FrontendError::Integrity);
}
let network = resolve_canonical_network_id_from_root(root, environment)?;
if registry.authority.binding.fleet.fleet.canonical_network_id != network {
return Err(FrontendError::Environment);
}
let admission_origin = current
.plan
.reviewed_desired
.as_deref()
.and_then(|review| review.desired().bootstrap.as_ref())
.and_then(|bootstrap| bootstrap.admission_identity_origin.clone());
Ok(FrontendAuthorityView {
app: registry.authority.binding.fleet.app.to_string(),
fleet: fleet.to_string(),
fleet_id: registry.authority.binding.fleet.fleet.fleet_id.to_string(),
source_plan_sha256: current.plan.plan_sha256.clone(),
network,
admission_nonempty: !registry.admission.fleet_principals.is_empty()
|| registry
.admission
.rules
.iter()
.any(|rule| !rule.principals.is_empty()),
admission_origin,
entries: current.registry.entries,
})
}
pub fn bindings(candid: &[u8]) -> Result<FrontendBindingsView, FrontendError> {
if candid.len() > MAX_FRONTEND_FILE_BYTES {
return Err(FrontendError::Bound("Candid bytes"));
}
let source =
std::str::from_utf8(candid).map_err(|error| FrontendError::Candid(error.to_string()))?;
let program: IDLProg = source
.parse()
.map_err(|error: candid_parser::Error| FrontendError::Candid(error.to_string()))?;
if program
.decs
.iter()
.any(|declaration| matches!(declaration, Dec::ImportType(_) | Dec::ImportServ(_)))
{
return Err(FrontendError::Candid(
"sidecar imports are not release-bound".to_string(),
));
}
let mut environment = TypeEnv::new();
let actor = candid_parser::check_prog(&mut environment, &program)
.map_err(|error| FrontendError::Candid(error.to_string()))?;
if actor.is_none() {
return Err(FrontendError::Candid("missing service".to_string()));
}
let javascript = javascript::compile(&environment, &actor).into_bytes();
let typescript =
typescript::compile(&environment, &actor, &IDLMergedProg::new(program)).into_bytes();
if javascript.len().max(typescript.len()) > MAX_FRONTEND_FILE_BYTES {
return Err(FrontendError::Bound("generated binding bytes"));
}
Ok(FrontendBindingsView {
javascript,
typescript,
})
}
fn insert_file(
files: &mut BTreeMap<String, Vec<u8>>,
path: String,
bytes: Vec<u8>,
) -> Result<FrontendFileRecord, FrontendError> {
let total = files.values().map(Vec::len).sum::<usize>();
if bytes.len() > MAX_FRONTEND_FILE_BYTES
|| total.saturating_add(bytes.len()) > MAX_FRONTEND_BUNDLE_BYTES
{
return Err(FrontendError::Bound("bundle bytes"));
}
let record = FrontendFileRecord {
path: path.clone(),
sha256: sha256_hex(&bytes),
bytes: bytes.len() as u64,
};
if files.insert(path, bytes).is_some() {
return Err(FrontendError::Integrity);
}
Ok(record)
}
pub fn prepare_bundle(
root: &Path,
input: &FrontendEnvironmentInput,
authority: FrontendAuthorityView,
) -> Result<FrontendBundleView, FrontendError> {
let mut files = BTreeMap::new();
let mut roles = Vec::new();
let mut selections = input.roles.iter().collect::<Vec<_>>();
selections.sort_by_key(|selection| (selection.role.as_str(), selection.canister_id));
for selected in selections {
let matches = authority
.entries
.iter()
.filter(|entry| {
entry.pid == selected.canister_id.to_text()
&& entry.role.as_deref() == Some(selected.role.as_str())
})
.collect::<Vec<_>>();
let [entry] = matches.as_slice() else {
return Err(FrontendError::Role(selected.role.to_string()));
};
let protocol = resolve_registry_protocol_binding(root, &input.environment, entry)?;
let candid = read_file_no_follow(protocol.candid_path(), MAX_FRONTEND_FILE_BYTES)
.map_err(std::io::Error::from)?;
if sha256_hex(&candid) != hex_bytes(protocol.binding().candid_sha256) {
return Err(FrontendError::Integrity);
}
let generated = bindings(&candid)?;
let prefix = format!("roles/{}/{}", selected.role, selected.canister_id);
roles.push(FrontendRoleRecord {
role: selected.role.clone(),
canister_id: selected.canister_id,
release_identity: protocol.binding().release_identity.clone(),
module_sha256: entry.module_hash.clone().ok_or(FrontendError::Integrity)?,
candid: insert_file(&mut files, format!("{prefix}.did"), candid)?,
javascript: insert_file(
&mut files,
format!("{prefix}.did.mjs"),
generated.javascript,
)?,
typescript: insert_file(
&mut files,
format!("{prefix}.did.d.ts"),
generated.typescript,
)?,
});
}
let alternative_origins = insert_file(
&mut files,
".well-known/ii-alternative-origins".to_string(),
serde_json::to_vec(
&serde_json::json!({"alternativeOrigins": input.identity.alternative_origins}),
)?,
)?;
let root_key = frontend_root_key(root, &input.environment)?;
if let Some(bytes) = &root_key {
let observed = canic_core::ids::CanonicalNetworkId::from_der_root_trust_anchor(bytes)
.map_err(|_| FrontendError::Integrity)?;
if observed != authority.network {
return Err(FrontendError::Environment);
}
}
let mut manifest = FrontendManifestRecord {
schema_version: 1,
manifest_sha256: String::new(),
generator: format!("canic-host/{}", env!("CARGO_PKG_VERSION")),
app: authority.app,
fleet: authority.fleet,
fleet_id: authority.fleet_id,
source_plan_sha256: authority.source_plan_sha256,
environment: input.environment.clone(),
canonical_network_id: authority.network,
api_origin: input.api_origin.clone(),
local_root_key_der_hex: root_key.map(hex_bytes),
identity: input.identity.clone(),
alternative_origins,
asset: input.asset.clone(),
roles,
};
manifest.manifest_sha256 = manifest_digest(&manifest)?;
Ok(FrontendBundleView { manifest, files })
}