feat: ship slim releases with shared on-demand extensions and locked restore
This commit is contained in:
1 parent
83b78a0db6
commit
4fda7c8c1a
58 files changed
+3774
-425
No files matched your search
@@ -0,0 +1,506 @@
|
||||
//! One signed catalog owns both the base release and immutable optional resources.
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::BuildTarget;
|
||||
use super::release::{
|
||||
self, Artifact, InstallLock, Layout, MachineManager, ReleaseManifest, Version,
|
||||
};
|
||||
use crate::libs::Ecosystem;
|
||||
|
||||
/// Explicit author/test transport. Product IPC always uses the compiled official source.
|
||||
pub trait ExtensionSource {
|
||||
fn open(&self, version: &Version, extension: &ExtensionId)
|
||||
-> Result<Box<dyn io::Read>, String>;
|
||||
}
|
||||
|
||||
pub(super) const PAYLOAD_LIMIT: u64 = 2 * 1024 * 1024 * 1024;
|
||||
pub(super) const ARTIFACT_LIMIT: usize = 4096;
|
||||
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
|
||||
#[serde(
|
||||
tag = "type",
|
||||
content = "ecosystem",
|
||||
rename_all = "snake_case",
|
||||
deny_unknown_fields
|
||||
)]
|
||||
pub enum ExtensionKind {
|
||||
Runtime(Ecosystem),
|
||||
Build(Ecosystem),
|
||||
Target,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExtensionId {
|
||||
pub kind: ExtensionKind,
|
||||
pub target: BuildTarget,
|
||||
}
|
||||
|
||||
impl ExtensionId {
|
||||
pub fn name(&self) -> String {
|
||||
let kind = match &self.kind {
|
||||
ExtensionKind::Runtime(ecosystem) => format!("runtime-{}", ecosystem.as_str()),
|
||||
ExtensionKind::Build(ecosystem) => format!("build-{}", ecosystem.as_str()),
|
||||
ExtensionKind::Target => "target".into(),
|
||||
};
|
||||
format!("{kind}-{}", self.target.platform())
|
||||
}
|
||||
|
||||
pub fn asset_suffix(&self) -> String {
|
||||
format!("ext-{}.tar.zst", self.name())
|
||||
}
|
||||
|
||||
fn permits(&self, path: &str) -> bool {
|
||||
let target = self.target.platform();
|
||||
match &self.kind {
|
||||
ExtensionKind::Runtime(ecosystem) => {
|
||||
path.starts_with(&format!("runtime/{}/{target}/", ecosystem.as_str()))
|
||||
}
|
||||
ExtensionKind::Build(ecosystem) => {
|
||||
path.starts_with(&format!("build/{}/{target}/", ecosystem.as_str()))
|
||||
}
|
||||
ExtensionKind::Target => {
|
||||
path.starts_with(&format!("runtime/{target}/"))
|
||||
|| path.starts_with(&format!("sandbox/{target}/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn missing(&self) -> String {
|
||||
match self.kind {
|
||||
ExtensionKind::Target => format!(
|
||||
"target resources are not installed; run 'dever target add {}'",
|
||||
self.target.platform()
|
||||
),
|
||||
_ => format!(
|
||||
"{} resources are not installed; run 'dever lib install <project-root> --target {}' (or 'dever lib add' for new dependencies)",
|
||||
self.name(),
|
||||
self.target.platform()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Extension {
|
||||
pub kind: ExtensionKind,
|
||||
pub target: BuildTarget,
|
||||
pub artifacts: Vec<Artifact>,
|
||||
}
|
||||
|
||||
impl Extension {
|
||||
pub fn id(&self) -> ExtensionId {
|
||||
ExtensionId {
|
||||
kind: self.kind.clone(),
|
||||
target: self.target,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_catalog(manifest: &ReleaseManifest) -> Result<(), String> {
|
||||
Version::parse(manifest.version.as_str())?;
|
||||
if manifest.format != "dever-release-v2" || manifest.platform != release::platform_identity() {
|
||||
return Err("unsupported release format or host platform".into());
|
||||
}
|
||||
if manifest.extensions.len() > 12 {
|
||||
return Err("release extension count exceeds its limit".into());
|
||||
}
|
||||
let mut paths = BTreeSet::new();
|
||||
validate_artifacts(&manifest.artifacts, &mut paths)?;
|
||||
let mut ids = BTreeSet::new();
|
||||
for extension in &manifest.extensions {
|
||||
let id = extension.id();
|
||||
if !ids.insert(id.clone())
|
||||
|| matches!(id.kind, ExtensionKind::Build(Ecosystem::Go))
|
||||
|| matches!(id.kind, ExtensionKind::Target) && id.target == BuildTarget::host()?
|
||||
{
|
||||
return Err("release has a duplicate or unsupported extension identity".into());
|
||||
}
|
||||
validate_artifacts(&extension.artifacts, &mut paths)?;
|
||||
if extension
|
||||
.artifacts
|
||||
.iter()
|
||||
.any(|artifact| !id.permits(&artifact.path))
|
||||
{
|
||||
return Err(format!(
|
||||
"extension '{}' contains a file outside its resource namespace",
|
||||
id.name()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_artifacts<'a>(
|
||||
artifacts: &'a [Artifact],
|
||||
paths: &mut BTreeSet<&'a str>,
|
||||
) -> Result<(), String> {
|
||||
if artifacts.is_empty() || artifacts.len() > ARTIFACT_LIMIT {
|
||||
return Err("release artifact count is outside 1..=4096".into());
|
||||
}
|
||||
let mut bytes = 0_u64;
|
||||
for artifact in artifacts {
|
||||
if artifact.path.is_empty()
|
||||
|| artifact.path.len() > 1024
|
||||
|| artifact
|
||||
.path
|
||||
.split('/')
|
||||
.any(|part| part.is_empty() || part == "." || part == "..")
|
||||
|| artifact.path.contains(['\\', ':', '\0'])
|
||||
|| matches!(artifact.path.as_str(), "manifest.json" | "manifest.sig")
|
||||
|| !paths.insert(&artifact.path)
|
||||
{
|
||||
return Err("release has an unsafe or duplicate artifact path".into());
|
||||
}
|
||||
if artifact.sha256.len() != 64
|
||||
|| !artifact
|
||||
.sha256
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
{
|
||||
return Err("release artifact has an invalid SHA-256".into());
|
||||
}
|
||||
bytes = bytes
|
||||
.checked_add(artifact.bytes)
|
||||
.ok_or("release size overflow")?;
|
||||
if bytes > PAYLOAD_LIMIT {
|
||||
return Err("release exceeds its two-GiB unpacked size limit".into());
|
||||
}
|
||||
}
|
||||
// A signed file cannot also be the parent directory of another file.
|
||||
for path in paths.iter() {
|
||||
for (index, _) in path.match_indices('/') {
|
||||
if paths.contains(&path[..index]) {
|
||||
return Err("release artifact paths overlap as file and directory".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SignedResources {
|
||||
layout: Layout,
|
||||
base: PathBuf,
|
||||
manifest: ReleaseManifest,
|
||||
catalog: String,
|
||||
}
|
||||
|
||||
impl SignedResources {
|
||||
pub fn load(layout: &Layout, version: &Version) -> Result<Self, String> {
|
||||
Version::parse(version.as_str())?;
|
||||
layout.validate_machine_permissions()?;
|
||||
let base = layout.versions().join(version.as_str());
|
||||
let manifest = MachineManager::new(layout.clone()).verify_release(&base, Some(version))?;
|
||||
release::validate_installed_release(layout, &base, &manifest)?;
|
||||
let catalog = release::sha256_file(&base.join("manifest.json"))?;
|
||||
Ok(Self {
|
||||
layout: layout.clone(),
|
||||
base,
|
||||
manifest,
|
||||
catalog,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn manifest(&self) -> &ReleaseManifest {
|
||||
&self.manifest
|
||||
}
|
||||
|
||||
pub fn root(&self, logical_path: &str) -> Result<PathBuf, String> {
|
||||
if let Some(artifact) = self
|
||||
.manifest
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.path == logical_path)
|
||||
{
|
||||
verify_file(&self.base, artifact)?;
|
||||
return Ok(self.base.clone());
|
||||
}
|
||||
let extension = self
|
||||
.manifest
|
||||
.extensions
|
||||
.iter()
|
||||
.find(|extension| {
|
||||
extension
|
||||
.artifacts
|
||||
.iter()
|
||||
.any(|artifact| artifact.path == logical_path)
|
||||
})
|
||||
.ok_or_else(|| format!("signed release does not declare resource '{logical_path}'"))?;
|
||||
self.extension_root(extension)
|
||||
}
|
||||
|
||||
pub fn path(&self, logical_path: &str) -> Result<PathBuf, String> {
|
||||
Ok(self.root(logical_path)?.join(logical_path))
|
||||
}
|
||||
|
||||
pub fn read(&self, logical_path: &str) -> Result<Vec<u8>, String> {
|
||||
let path = self.path(logical_path)?;
|
||||
let artifact = self
|
||||
.manifest
|
||||
.artifacts
|
||||
.iter()
|
||||
.chain(
|
||||
self.manifest
|
||||
.extensions
|
||||
.iter()
|
||||
.flat_map(|extension| &extension.artifacts),
|
||||
)
|
||||
.find(|artifact| artifact.path == logical_path)
|
||||
.expect("root required signed artifact");
|
||||
let bytes =
|
||||
fs::read(path).map_err(|error| format!("cannot read signed resource: {error}"))?;
|
||||
use sha2::{Digest, Sha256};
|
||||
if bytes.len() as u64 != artifact.bytes
|
||||
|| release::hex(&Sha256::digest(&bytes)) != artifact.sha256
|
||||
{
|
||||
return Err("signed resource changed while reading".into());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub fn artifacts(&self, prefix: &str) -> Result<Vec<Artifact>, String> {
|
||||
let mut artifacts = Vec::new();
|
||||
for artifact in self
|
||||
.manifest
|
||||
.artifacts
|
||||
.iter()
|
||||
.filter(|artifact| artifact.path.starts_with(prefix))
|
||||
{
|
||||
verify_file(&self.base, artifact)?;
|
||||
artifacts.push(artifact.clone());
|
||||
}
|
||||
for extension in &self.manifest.extensions {
|
||||
if extension
|
||||
.artifacts
|
||||
.iter()
|
||||
.any(|artifact| artifact.path.starts_with(prefix))
|
||||
{
|
||||
self.extension_root(extension)?;
|
||||
artifacts.extend(
|
||||
extension
|
||||
.artifacts
|
||||
.iter()
|
||||
.filter(|artifact| artifact.path.starts_with(prefix))
|
||||
.cloned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(artifacts)
|
||||
}
|
||||
|
||||
fn store(&self) -> PathBuf {
|
||||
self.layout
|
||||
.extensions()
|
||||
.join(self.manifest.version.as_str())
|
||||
.join(&self.catalog)
|
||||
}
|
||||
|
||||
fn destination(&self, id: &ExtensionId) -> PathBuf {
|
||||
self.store().join(id.name())
|
||||
}
|
||||
|
||||
fn extension(&self, id: &ExtensionId) -> Result<&Extension, String> {
|
||||
self.manifest
|
||||
.extensions
|
||||
.iter()
|
||||
.find(|extension| extension.id() == *id)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Dever {} does not declare extension '{}'",
|
||||
self.manifest.version,
|
||||
id.name()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn extension_root(&self, extension: &Extension) -> Result<PathBuf, String> {
|
||||
let id = extension.id();
|
||||
let root = self.destination(&id);
|
||||
if !self.store_present()? || !exists(&root)? {
|
||||
return Err(id.missing());
|
||||
}
|
||||
verify_extension(&root, &extension.artifacts)?;
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
fn store_present(&self) -> Result<bool, String> {
|
||||
let mut parent = self.layout.cache();
|
||||
for directory in [
|
||||
self.layout.extensions(),
|
||||
self.layout
|
||||
.extensions()
|
||||
.join(self.manifest.version.as_str()),
|
||||
self.store(),
|
||||
] {
|
||||
if !exists(&directory)? {
|
||||
return Ok(false);
|
||||
}
|
||||
release::validate_shared_directory(&directory, &parent, "extension store")?;
|
||||
parent = directory;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(super) fn installed_extensions(&self) -> Result<Vec<ExtensionId>, String> {
|
||||
if !self.store_present()? {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut installed = Vec::new();
|
||||
for entry in fs::read_dir(self.store()).map_err(|error| error.to_string())? {
|
||||
let entry = entry.map_err(|error| error.to_string())?;
|
||||
let extension = self
|
||||
.manifest
|
||||
.extensions
|
||||
.iter()
|
||||
.find(|extension| entry.file_name() == extension.id().name().as_str())
|
||||
.ok_or("extension store contains an undeclared entry")?;
|
||||
self.extension_root(extension)?;
|
||||
installed.push(extension.id());
|
||||
}
|
||||
Ok(installed)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn exists(path: &Path) -> Result<bool, String> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
|
||||
Err(error) => Err(format!(
|
||||
"cannot inspect extension path '{}': {error}",
|
||||
path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_file(root: &Path, artifact: &Artifact) -> Result<(), String> {
|
||||
let mut parent = root.to_path_buf();
|
||||
let relative = Path::new(&artifact.path);
|
||||
for component in relative.parent().into_iter().flat_map(Path::components) {
|
||||
let directory = parent.join(component);
|
||||
release::validate_shared_directory(&directory, &parent, "signed resource directory")?;
|
||||
parent = directory;
|
||||
}
|
||||
let path = root.join(relative);
|
||||
release::validate_shared_file(&path, &parent, "signed resource", false)?;
|
||||
if fs::metadata(&path)
|
||||
.map_err(|error| error.to_string())?
|
||||
.len()
|
||||
!= artifact.bytes
|
||||
|| release::sha256_file(&path)? != artifact.sha256
|
||||
{
|
||||
return Err(format!(
|
||||
"signed resource '{}' failed integrity verification",
|
||||
artifact.path
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn verify_extension(root: &Path, artifacts: &[Artifact]) -> Result<(), String> {
|
||||
release::validate_shared_directory(
|
||||
root,
|
||||
root.parent().ok_or("extension root has no parent")?,
|
||||
"installed extension",
|
||||
)?;
|
||||
for artifact in artifacts {
|
||||
verify_file(root, artifact)?;
|
||||
}
|
||||
fn entries(root: &Path, directory: &Path, expected: &BTreeSet<&str>) -> Result<(), String> {
|
||||
for entry in fs::read_dir(directory).map_err(|error| error.to_string())? {
|
||||
let entry = entry.map_err(|error| error.to_string())?;
|
||||
let path = entry.path();
|
||||
let kind = entry.file_type().map_err(|error| error.to_string())?;
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.map_err(|error| error.to_string())?
|
||||
.to_str()
|
||||
.ok_or("extension path is not UTF-8")?;
|
||||
if kind.is_dir() {
|
||||
release::validate_shared_directory(&path, directory, "extension directory")?;
|
||||
if !expected
|
||||
.iter()
|
||||
.any(|artifact| artifact.starts_with(&format!("{relative}/")))
|
||||
{
|
||||
return Err("installed extension contains an unsigned directory".into());
|
||||
}
|
||||
entries(root, &path, expected)?;
|
||||
} else if !kind.is_file() || !expected.contains(relative) {
|
||||
return Err("installed extension contains an unsigned or non-regular file".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
entries(
|
||||
root,
|
||||
root,
|
||||
&artifacts
|
||||
.iter()
|
||||
.map(|artifact| artifact.path.as_str())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn ensure(layout: &Layout, version: &Version, id: &ExtensionId) -> Result<(), String> {
|
||||
prepare_extension_with(
|
||||
layout,
|
||||
version,
|
||||
id,
|
||||
&super::release_source::ReleaseSource::official(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn prepare_extension_with(
|
||||
layout: &Layout,
|
||||
version: &Version,
|
||||
id: &ExtensionId,
|
||||
source: &dyn ExtensionSource,
|
||||
) -> Result<(), String> {
|
||||
Version::parse(version.as_str())?;
|
||||
let _lock = InstallLock::acquire_wait(layout)?;
|
||||
ensure_locked(layout, version, id, source)
|
||||
}
|
||||
|
||||
pub(super) fn ensure_locked(
|
||||
layout: &Layout,
|
||||
version: &Version,
|
||||
id: &ExtensionId,
|
||||
source: &dyn ExtensionSource,
|
||||
) -> Result<(), String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
super::bootstrap::require_no_pending(layout)?;
|
||||
let resources = SignedResources::load(layout, version)?;
|
||||
let extension = resources.extension(id)?;
|
||||
let destination = resources.destination(id);
|
||||
if exists(&destination)? {
|
||||
resources.extension_root(extension)?;
|
||||
return Ok(());
|
||||
}
|
||||
let mut parent = layout.cache();
|
||||
for directory in [
|
||||
layout.extensions(),
|
||||
layout.extensions().join(version.as_str()),
|
||||
resources.store(),
|
||||
] {
|
||||
if !exists(&directory)? {
|
||||
fs::create_dir(&directory)
|
||||
.map_err(|error| format!("cannot create extension store: {error}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&directory, fs::Permissions::from_mode(0o755))
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
release::sync_directory(&parent).map_err(|error| error.to_string())?;
|
||||
}
|
||||
release::validate_shared_directory(&directory, &parent, "extension store")?;
|
||||
parent = directory;
|
||||
}
|
||||
super::release_source::prepare_extension(layout, version, extension, &destination, source)
|
||||
}
|
||||
Reference in new issue
Block a user