Use Gitea Releases for installation and ship VS Code highlighting
This commit is contained in:
1 parent
a46bd6e132
commit
201bdee372
243 files changed
+3289
-12521
No files matched your search
@@ -46,13 +46,14 @@ pub fn execute_in(layout: &Layout, arguments: &[OsString]) -> Result<CommandResu
|
||||
}
|
||||
[command, requested] if command == "install" => {
|
||||
let requested = utf8(requested, "version")?;
|
||||
super::release_source::prepare(layout, requested)?;
|
||||
let version = manager.install_requested(requested)?;
|
||||
let version = super::release_source::prepare(layout, requested)?;
|
||||
let version = manager.install_requested(version.as_str())?;
|
||||
Ok(CommandResult::Message(format!("installed Dever {version}")))
|
||||
}
|
||||
[command] if command == "update" => {
|
||||
super::release_source::prepare(layout, "latest")?;
|
||||
let version = manager.update()?;
|
||||
let source = super::release_source::ReleaseSource::official();
|
||||
let version = super::release_source::prepare_from(layout, "latest", &source)?;
|
||||
let version = manager.update_version_with(&version, &source)?;
|
||||
Ok(CommandResult::Message(format!(
|
||||
"updated Dever and dever-language skill to {version}"
|
||||
)))
|
||||
|
||||
@@ -318,27 +318,35 @@ impl MachineManager {
|
||||
/// Uses the ordinary install transaction with an explicitly supplied author transport.
|
||||
pub fn update_with(&self, source: &dyn super::ExtensionSource) -> Result<Version, String> {
|
||||
let version = self.releases.latest()?;
|
||||
self.update_version_with(&version, source)
|
||||
}
|
||||
|
||||
pub(super) fn update_version_with(
|
||||
&self,
|
||||
version: &Version,
|
||||
source: &dyn super::ExtensionSource,
|
||||
) -> Result<Version, String> {
|
||||
let _lock = InstallLock::acquire(&self.layout)?;
|
||||
#[cfg(target_os = "linux")]
|
||||
super::bootstrap::require_no_pending(&self.layout)?;
|
||||
if self
|
||||
.active_version()?
|
||||
.is_some_and(|active| version < active)
|
||||
.is_some_and(|active| version < &active)
|
||||
{
|
||||
return Err(format!(
|
||||
"refusing to downgrade active Dever with 'dever update' to {version}; use 'dever use {version}' for an explicit installed-version switch"
|
||||
));
|
||||
}
|
||||
self.cleanup_staging()?;
|
||||
self.install_locked(&version)?;
|
||||
self.install_locked(version)?;
|
||||
if let Some(active) = self.active_version()? {
|
||||
let resources = super::extensions::SignedResources::load(&self.layout, &active)?;
|
||||
for extension in resources.installed_extensions()? {
|
||||
super::extensions::ensure_locked(&self.layout, &version, &extension, source)?;
|
||||
super::extensions::ensure_locked(&self.layout, version, &extension, source)?;
|
||||
}
|
||||
}
|
||||
self.activate_locked(&version)?;
|
||||
Ok(version)
|
||||
self.activate_locked(version)?;
|
||||
Ok(version.clone())
|
||||
}
|
||||
|
||||
pub fn activate(&self, version: &Version) -> Result<(), String> {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
//! Explicit installation/update downloads. Project commands never enter here.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::extensions::Extension;
|
||||
#[cfg(test)]
|
||||
use super::extensions::PAYLOAD_LIMIT as RELEASE_LIMIT;
|
||||
#[cfg(test)]
|
||||
use super::release::ReleaseManifest;
|
||||
@@ -18,14 +20,87 @@ use super::release::{
|
||||
};
|
||||
use super::validate_catalog;
|
||||
|
||||
const OFFICIAL_RELEASES: &str = "https://git.dever.cc/shemic/dever/dist";
|
||||
const OFFICIAL_RELEASES: &str = "https://git.dever.cc/api/v1/repos/shemic/dever/releases";
|
||||
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
const METADATA_LIMIT: u64 = 2 * 1024 * 1024;
|
||||
const ASSET_LIMIT: usize = 512;
|
||||
static NEXT_DOWNLOAD: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub(super) struct ReleaseSource {
|
||||
base: String,
|
||||
base: url::Url,
|
||||
agent: ureq::Agent,
|
||||
releases: RefCell<BTreeMap<String, Rc<ResolvedRelease>>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ReleaseMetadata {
|
||||
tag_name: String,
|
||||
draft: bool,
|
||||
prerelease: bool,
|
||||
assets: Vec<ReleaseAsset>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ReleaseAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
struct ResolvedRelease {
|
||||
version: Version,
|
||||
assets: Vec<ReleaseAsset>,
|
||||
}
|
||||
|
||||
impl ResolvedRelease {
|
||||
fn parse(bytes: &[u8], requested: &str) -> Result<Self, String> {
|
||||
let metadata: ReleaseMetadata =
|
||||
serde_json::from_slice(bytes).map_err(|_| "invalid official release metadata")?;
|
||||
if metadata.draft || metadata.prerelease {
|
||||
return Err("official release must be published and stable".into());
|
||||
}
|
||||
let version = Version::parse(
|
||||
metadata
|
||||
.tag_name
|
||||
.strip_prefix('v')
|
||||
.ok_or("invalid official release tag")?,
|
||||
)?;
|
||||
if requested != "latest" && version.as_str() != requested {
|
||||
return Err("official release tag does not match the requested version".into());
|
||||
}
|
||||
if metadata.assets.is_empty() || metadata.assets.len() > ASSET_LIMIT {
|
||||
return Err("official release asset count is outside its limits".into());
|
||||
}
|
||||
let mut names = BTreeSet::new();
|
||||
for asset in &metadata.assets {
|
||||
if asset.name.is_empty()
|
||||
|| asset.name.len() > 255
|
||||
|| !asset
|
||||
.name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"-_.".contains(&byte))
|
||||
|| asset.size == 0
|
||||
|| asset.size > RELEASE_LIMIT
|
||||
{
|
||||
return Err("invalid official release asset metadata".into());
|
||||
}
|
||||
if !names.insert(&asset.name) {
|
||||
return Err("official release contains duplicate asset names".into());
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
version,
|
||||
assets: metadata.assets,
|
||||
})
|
||||
}
|
||||
|
||||
fn asset(&self, suffix: &str) -> Result<&ReleaseAsset, String> {
|
||||
let name = format!("dever-{}.{suffix}", platform_identity());
|
||||
self.assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == name)
|
||||
.ok_or_else(|| format!("official release is missing required asset '{name}'"))
|
||||
}
|
||||
}
|
||||
|
||||
impl ReleaseSource {
|
||||
@@ -35,7 +110,8 @@ impl ReleaseSource {
|
||||
|
||||
fn new(base: String) -> Self {
|
||||
Self {
|
||||
base,
|
||||
base: url::Url::parse(&base).expect("release API origin is compiled in"),
|
||||
releases: RefCell::new(BTreeMap::new()),
|
||||
agent: ureq::Agent::new_with_config(
|
||||
ureq::Agent::config_builder()
|
||||
.proxy(None)
|
||||
@@ -46,19 +122,33 @@ impl ReleaseSource {
|
||||
}
|
||||
}
|
||||
|
||||
fn asset(&self, requested: &str, suffix: &str) -> String {
|
||||
format!(
|
||||
"{}/{requested}/dever-{}.{suffix}",
|
||||
self.base,
|
||||
platform_identity()
|
||||
)
|
||||
fn resolve(&self, requested: &str, started: Instant) -> Result<Rc<ResolvedRelease>, String> {
|
||||
let selection = if requested == "latest" {
|
||||
"latest".to_owned()
|
||||
} else {
|
||||
Version::parse(requested)?;
|
||||
format!("tags/v{requested}")
|
||||
};
|
||||
if let Some(release) = self.releases.borrow().get(requested) {
|
||||
return Ok(Rc::clone(release));
|
||||
}
|
||||
let bytes = self.read_metadata(format!("{}/{selection}", self.base), started)?;
|
||||
let release = Rc::new(ResolvedRelease::parse(&bytes, requested)?);
|
||||
for asset in &release.assets {
|
||||
validate_download_url(&asset.browser_download_url, &self.base)?;
|
||||
}
|
||||
let mut releases = self.releases.borrow_mut();
|
||||
releases.insert(release.version.to_string(), Rc::clone(&release));
|
||||
releases.insert(requested.to_owned(), Rc::clone(&release));
|
||||
Ok(release)
|
||||
}
|
||||
|
||||
fn response(
|
||||
&self,
|
||||
mut address: String,
|
||||
address: String,
|
||||
started: Instant,
|
||||
) -> Result<ureq::http::Response<ureq::Body>, String> {
|
||||
let mut address: String = validate_download_url(&address, &self.base)?.into();
|
||||
for _ in 0..=5 {
|
||||
let remaining = DOWNLOAD_TIMEOUT
|
||||
.checked_sub(started.elapsed())
|
||||
@@ -94,19 +184,13 @@ impl ReleaseSource {
|
||||
if locations.next().is_some() {
|
||||
return Err("release redirect has multiple Locations".into());
|
||||
}
|
||||
address = redirect(&address, location)?;
|
||||
address = redirect(&address, location, &self.base)?;
|
||||
}
|
||||
Err("official release download exceeded its redirect limit".into())
|
||||
}
|
||||
|
||||
fn metadata(
|
||||
&self,
|
||||
requested: &str,
|
||||
suffix: &str,
|
||||
path: &Path,
|
||||
started: Instant,
|
||||
) -> Result<(), String> {
|
||||
let mut response = self.response(self.asset(requested, suffix), started)?;
|
||||
fn read_metadata(&self, address: String, started: Instant) -> Result<Vec<u8>, String> {
|
||||
let mut response = self.response(address, started)?;
|
||||
let mut bytes = Vec::new();
|
||||
response
|
||||
.body_mut()
|
||||
@@ -117,8 +201,27 @@ impl ReleaseSource {
|
||||
if bytes.len() as u64 > METADATA_LIMIT {
|
||||
return Err("official release metadata exceeds its size limit".into());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn metadata(&self, asset: &ReleaseAsset, path: &Path, started: Instant) -> Result<(), String> {
|
||||
if asset.size > METADATA_LIMIT {
|
||||
return Err("official release metadata exceeds its size limit".into());
|
||||
}
|
||||
let bytes = self.read_metadata(asset.browser_download_url.clone(), started)?;
|
||||
if bytes.len() as u64 != asset.size {
|
||||
return Err("official release asset size does not match its metadata".into());
|
||||
}
|
||||
write_new(path, &bytes)
|
||||
}
|
||||
|
||||
fn open_asset(&self, asset: &ReleaseAsset, started: Instant) -> Result<Box<dyn Read>, String> {
|
||||
let response = self.response(asset.browser_download_url.clone(), started)?;
|
||||
Ok(Box::new(AssetReader {
|
||||
reader: response.into_body().into_reader(),
|
||||
remaining: asset.size,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl super::ExtensionSource for ReleaseSource {
|
||||
@@ -127,30 +230,58 @@ impl super::ExtensionSource for ReleaseSource {
|
||||
version: &Version,
|
||||
extension: &super::ExtensionId,
|
||||
) -> Result<Box<dyn Read>, String> {
|
||||
Ok(Box::new(
|
||||
self.response(
|
||||
self.asset(version.as_str(), &extension.asset_suffix()),
|
||||
Instant::now(),
|
||||
)?
|
||||
.into_body()
|
||||
.into_reader(),
|
||||
))
|
||||
let started = Instant::now();
|
||||
let release = self.resolve(version.as_str(), started)?;
|
||||
self.open_asset(release.asset(&extension.asset_suffix())?, started)
|
||||
}
|
||||
}
|
||||
|
||||
fn redirect(current: &str, location: &str) -> Result<String, String> {
|
||||
struct AssetReader<R> {
|
||||
reader: R,
|
||||
remaining: u64,
|
||||
}
|
||||
|
||||
impl<R: Read> Read for AssetReader<R> {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
|
||||
if buffer.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
if self.remaining == 0 {
|
||||
if self.reader.read(&mut [0])? != 0 {
|
||||
return Err(io::Error::other("release asset exceeds its declared size"));
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
let limit = self.remaining.min(buffer.len() as u64) as usize;
|
||||
let count = self.reader.read(&mut buffer[..limit])?;
|
||||
if count == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"release asset is shorter than its declared size",
|
||||
));
|
||||
}
|
||||
self.remaining -= count as u64;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_download_url(address: &str, origin: &url::Url) -> Result<url::Url, String> {
|
||||
let next = url::Url::parse(address).map_err(|_| "invalid release asset URL")?;
|
||||
if next.origin() != origin.origin()
|
||||
|| !next.username().is_empty()
|
||||
|| next.password().is_some()
|
||||
|| next.fragment().is_some()
|
||||
{
|
||||
return Err("release download must remain on the official HTTPS asset origin".into());
|
||||
}
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
fn redirect(current: &str, location: &str, origin: &url::Url) -> Result<String, String> {
|
||||
let next = url::Url::parse(current)
|
||||
.and_then(|base| base.join(location))
|
||||
.map_err(|_| "invalid release redirect")?;
|
||||
if next.scheme() != "https"
|
||||
|| !next.username().is_empty()
|
||||
|| next.password().is_some()
|
||||
|| next.port_or_known_default() != Some(443)
|
||||
|| next.fragment().is_some()
|
||||
|| next.host_str() != Some("git.dever.cc")
|
||||
{
|
||||
return Err("release redirect must remain on the official HTTPS asset hosts".into());
|
||||
}
|
||||
validate_download_url(next.as_str(), origin)?;
|
||||
Ok(next.into())
|
||||
}
|
||||
|
||||
@@ -158,7 +289,7 @@ pub(super) fn prepare(layout: &Layout, requested: &str) -> Result<Version, Strin
|
||||
prepare_from(layout, requested, &ReleaseSource::official())
|
||||
}
|
||||
|
||||
fn prepare_from(
|
||||
pub(super) fn prepare_from(
|
||||
layout: &Layout,
|
||||
requested: &str,
|
||||
source: &ReleaseSource,
|
||||
@@ -190,15 +321,14 @@ fn prepare_from(
|
||||
let key = decode_hex_file(&key_path, 32)?;
|
||||
let staging = Download::new(&layout.staging())?;
|
||||
let started = Instant::now();
|
||||
let release = source.resolve(requested, started)?;
|
||||
source.metadata(
|
||||
requested,
|
||||
"manifest.json",
|
||||
release.asset("manifest.json")?,
|
||||
&staging.0.join("manifest.json"),
|
||||
started,
|
||||
)?;
|
||||
source.metadata(
|
||||
requested,
|
||||
"manifest.sig",
|
||||
release.asset("manifest.sig")?,
|
||||
&staging.0.join("manifest.sig"),
|
||||
started,
|
||||
)?;
|
||||
@@ -210,10 +340,11 @@ fn prepare_from(
|
||||
{
|
||||
return Err("official releases must include the matching dever-language skill".into());
|
||||
}
|
||||
if requested != "latest" && manifest.version.as_str() != requested {
|
||||
return Err("downloaded release version does not match the requested version".into());
|
||||
if manifest.version != release.version {
|
||||
return Err("downloaded release version does not match the selected release tag".into());
|
||||
}
|
||||
validate_catalog(&manifest)?;
|
||||
let archive = release.asset("tar.zst")?;
|
||||
let version = manifest.version.clone();
|
||||
let destination = layout.downloads().join(version.as_str());
|
||||
match fs::symlink_metadata(&destination) {
|
||||
@@ -226,10 +357,8 @@ fn prepare_from(
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
let mut response =
|
||||
source.response(source.asset(version.as_str(), "tar.zst"), started)?;
|
||||
super::release_archive::extract(
|
||||
response.body_mut().as_reader(),
|
||||
source.open_asset(archive, started)?,
|
||||
&staging.0,
|
||||
&manifest.artifacts,
|
||||
)?;
|
||||
|
||||
Reference in new issue
Block a user