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
+1
-1
@@ -44,7 +44,7 @@
|
||||
|
||||
替换路径,勿直接复制示例运行。下文 CI 字段均位于 `performance.linux_ci`。`inputs` 列出固定原始作者输入及准备好的资产文件/目录,逐文件计 SHA-256;工具、协议产物、CMS manifest/可执行文件/配置和 `performance.network_peer/live_peer` 也自动计摘要,`linux_ci` 对象不作为 peer 路径解析。输出目录不得包含在 inputs 中。报告只存摘要,不复制数据库密码。
|
||||
|
||||
`required_inputs` 为 `author`、`native`、`postgres`、`sandbox`、`bounded-perf`、`soak` 各提供非空绝对路径列表,必须落在 `inputs` 声明范围内。缺路径记录 blocked,不算 pass。作者 fixture 的具体固定路径/准备命令仍以 `sdk/native-release.md`、`test/ecosystem-release/prepare.py`、`test/ecosystem-release/build_inputs.py` 和对应 ignored 原因文本为准;gate 不把现存文件冒充由当前源码重建的 provenance。
|
||||
`required_inputs` 为 `author`、`native`、`postgres`、`sandbox`、`bounded-perf`、`soak` 各提供非空绝对路径列表,必须落在 `inputs` 声明范围内。缺路径记录 blocked,不算 pass。作者 fixture 的具体固定路径/准备命令仍以 `test/ecosystem-release/prepare.py`、`test/ecosystem-release/build_inputs.py` 和对应 ignored 原因文本为准;gate 不把现存文件冒充由当前源码重建的 provenance。
|
||||
|
||||
显式 LLVM Worker 验收读取 `target/native-release-inputs/component-fixture`:先用当前源码构建 `dever-tests` 的 `component-fixture` bin,再将程序复制到该准备目录,记录源码/程序摘要,并把固定副本列入 `inputs` 和 `required_inputs.native`。不能将 `target/debug/component-fixture` 当作不可变输入:Cargo 在 default/all-feature 组合间会重新链接这个输出。普通 Worker 测试仍使用 Cargo 为各自组合生成的 `CARGO_BIN_EXE_component-fixture`,不改质量门的输入变更拒绝规则。
|
||||
|
||||
|
||||
@@ -108,15 +108,17 @@ fn signed_catalog_paths_and_total_size_are_bounded_before_extraction() {
|
||||
|
||||
#[test]
|
||||
fn official_redirects_reject_http_credentials_ports_and_foreign_hosts() {
|
||||
let current = "https://git.dever.cc/shemic/dever/dist/latest/a";
|
||||
let current = "https://git.dever.cc/attachments/a";
|
||||
let origin = url::Url::parse(OFFICIAL_RELEASES).unwrap();
|
||||
assert!(
|
||||
redirect(
|
||||
current,
|
||||
"https://git.dever.cc/shemic/dever/dist/1.2.3/a?token=opaque"
|
||||
"https://git.dever.cc/attachments/b?token=opaque",
|
||||
&origin,
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(redirect(current, "/shemic/dever/dist/1.2.3/a").is_ok());
|
||||
assert!(redirect(current, "/attachments/b", &origin).is_ok());
|
||||
for address in [
|
||||
"http://git.dever.cc/a",
|
||||
"https://evil.example/a",
|
||||
@@ -127,34 +129,109 @@ fn official_redirects_reject_http_credentials_ports_and_foreign_hosts() {
|
||||
"https://github.com/a",
|
||||
"https://release-assets.githubusercontent.com/a",
|
||||
] {
|
||||
assert!(redirect(current, address).is_err(), "{address}");
|
||||
assert!(redirect(current, address, &origin).is_err(), "{address}");
|
||||
assert!(
|
||||
validate_download_url(address, &origin).is_err(),
|
||||
"{address}"
|
||||
);
|
||||
}
|
||||
assert!(validate_download_url("/attachments/b", &origin).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_assets_use_exact_platform_names_and_return_the_published_url() {
|
||||
let suffix = "ext-runtime-pip-linux-x86_64.tar.zst";
|
||||
let metadata = release_metadata("https://git.dever.cc", "v1.2.3", &[(suffix, b"archive")]);
|
||||
let release = ResolvedRelease::parse(&metadata, "latest").unwrap();
|
||||
assert_eq!(release.version.as_str(), "1.2.3");
|
||||
assert_eq!(
|
||||
release.asset(suffix).unwrap().browser_download_url,
|
||||
"https://git.dever.cc/attachments/0"
|
||||
);
|
||||
assert!(release.asset("manifest.json").is_err());
|
||||
assert!(
|
||||
release
|
||||
.asset("ext-runtime-pip-linux-aarch64.tar.zst")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_metadata_rejects_unstable_tags_mismatched_versions_and_malformed_assets() {
|
||||
let good: serde_json::Value = serde_json::from_slice(&release_metadata(
|
||||
"https://git.dever.cc",
|
||||
"v1.2.3",
|
||||
&[("tar.zst", b"archive")],
|
||||
))
|
||||
.unwrap();
|
||||
for tag in [
|
||||
"1.2.3",
|
||||
"v01.2.3",
|
||||
"v1.2",
|
||||
"v1.2.3-rc.1",
|
||||
"v1.2.3+build",
|
||||
"v../escape",
|
||||
] {
|
||||
let mut metadata = good.clone();
|
||||
metadata["tag_name"] = tag.into();
|
||||
assert!(
|
||||
ResolvedRelease::parse(&serde_json::to_vec(&metadata).unwrap(), "latest").is_err(),
|
||||
"{tag}"
|
||||
);
|
||||
}
|
||||
assert!(ResolvedRelease::parse(&serde_json::to_vec(&good).unwrap(), "1.2.4").is_err());
|
||||
for field in ["draft", "prerelease"] {
|
||||
let mut metadata = good.clone();
|
||||
metadata[field] = true.into();
|
||||
assert!(ResolvedRelease::parse(&serde_json::to_vec(&metadata).unwrap(), "latest").is_err());
|
||||
metadata.as_object_mut().unwrap().remove(field);
|
||||
assert!(ResolvedRelease::parse(&serde_json::to_vec(&metadata).unwrap(), "latest").is_err());
|
||||
}
|
||||
let asset = good["assets"][0].clone();
|
||||
for assets in [
|
||||
serde_json::json!([]),
|
||||
serde_json::json!([asset, asset]),
|
||||
serde_json::json!(vec![asset.clone(); ASSET_LIMIT + 1]),
|
||||
] {
|
||||
let mut metadata = good.clone();
|
||||
metadata["assets"] = assets;
|
||||
assert!(ResolvedRelease::parse(&serde_json::to_vec(&metadata).unwrap(), "latest").is_err());
|
||||
}
|
||||
for size in [
|
||||
serde_json::json!(0),
|
||||
serde_json::json!(-1),
|
||||
serde_json::json!(1.5),
|
||||
serde_json::json!(RELEASE_LIMIT + 1),
|
||||
serde_json::json!("7"),
|
||||
] {
|
||||
let mut metadata = good.clone();
|
||||
metadata["assets"][0]["size"] = size;
|
||||
assert!(ResolvedRelease::parse(&serde_json::to_vec(&metadata).unwrap(), "latest").is_err());
|
||||
}
|
||||
for name in ["", "../asset", "a/b", "a\\b"] {
|
||||
let mut metadata = good.clone();
|
||||
metadata["assets"][0]["name"] = name.into();
|
||||
assert!(ResolvedRelease::parse(&serde_json::to_vec(&metadata).unwrap(), "latest").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_asset_names_are_versioned_and_platform_specific() {
|
||||
let source = ReleaseSource::official();
|
||||
assert_eq!(
|
||||
source.asset("latest", "manifest.json"),
|
||||
format!(
|
||||
"https://git.dever.cc/shemic/dever/dist/latest/dever-{}.manifest.json",
|
||||
platform_identity()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
source.asset("1.2.3", "tar.zst"),
|
||||
format!(
|
||||
"https://git.dever.cc/shemic/dever/dist/1.2.3/dever-{}.tar.zst",
|
||||
platform_identity()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
source.asset("1.2.3", "ext-runtime-pip-linux-x86_64.tar.zst"),
|
||||
format!(
|
||||
"https://git.dever.cc/shemic/dever/dist/1.2.3/dever-{}.ext-runtime-pip-linux-x86_64.tar.zst",
|
||||
platform_identity()
|
||||
)
|
||||
);
|
||||
fn release_asset_reader_requires_the_exact_declared_size() {
|
||||
for size in [2, 4] {
|
||||
let mut reader = AssetReader {
|
||||
reader: io::Cursor::new(b"abc"),
|
||||
remaining: size,
|
||||
};
|
||||
assert!(reader.read_to_end(&mut Vec::new()).is_err());
|
||||
}
|
||||
let mut reader = AssetReader {
|
||||
reader: io::Cursor::new(b"abc"),
|
||||
remaining: 3,
|
||||
};
|
||||
assert_eq!(reader.read(&mut []).unwrap(), 0);
|
||||
let mut bytes = Vec::new();
|
||||
reader.read_to_end(&mut bytes).unwrap();
|
||||
assert_eq!(bytes, b"abc");
|
||||
}
|
||||
|
||||
fn signed_release() -> (ReleaseManifest, Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
@@ -177,13 +254,57 @@ fn signed_release() -> (ReleaseManifest, Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
}
|
||||
|
||||
fn server(responses: Vec<Vec<u8>>) -> (ReleaseSource, std::thread::JoinHandle<()>) {
|
||||
release_server("latest", "v1.2.3", responses)
|
||||
}
|
||||
|
||||
fn release_metadata(base: &str, tag: &str, assets: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"tag_name": tag,
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"assets": assets.iter().enumerate().map(|(index, (suffix, bytes))| serde_json::json!({
|
||||
"name": format!("dever-{}.{suffix}", platform_identity()),
|
||||
"browser_download_url": format!("{base}/attachments/{index}"),
|
||||
"size": bytes.len(),
|
||||
})).collect::<Vec<_>>(),
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn release_server(
|
||||
selection: &str,
|
||||
tag: &str,
|
||||
responses: Vec<Vec<u8>>,
|
||||
) -> (ReleaseSource, std::thread::JoinHandle<()>) {
|
||||
serve(|base| {
|
||||
let assets: Vec<_> = ["manifest.json", "manifest.sig", "tar.zst"]
|
||||
.into_iter()
|
||||
.zip(&responses)
|
||||
.map(|(suffix, bytes)| (suffix, bytes.as_slice()))
|
||||
.collect();
|
||||
let metadata = release_metadata(base, tag, &assets);
|
||||
std::iter::once((format!("/releases/{selection}"), metadata))
|
||||
.chain(
|
||||
responses
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, bytes)| (format!("/attachments/{index}"), bytes)),
|
||||
)
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn serve(
|
||||
responses: impl FnOnce(&str) -> Vec<(String, Vec<u8>)>,
|
||||
) -> (ReleaseSource, std::thread::JoinHandle<()>) {
|
||||
use std::io::BufRead;
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let base = format!("http://{}/releases", listener.local_addr().unwrap());
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let responses = responses(&base);
|
||||
let worker = std::thread::spawn(move || {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
for (index, response) in responses.into_iter().enumerate() {
|
||||
for (path, response) in responses {
|
||||
let mut stream = loop {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => break stream,
|
||||
@@ -203,18 +324,7 @@ fn server(responses: Vec<Vec<u8>>) -> (ReleaseSource, std::thread::JoinHandle<()
|
||||
let mut reader = io::BufReader::new(stream.try_clone().unwrap());
|
||||
let mut request = String::new();
|
||||
reader.read_line(&mut request).unwrap();
|
||||
let (selection, suffix) = [
|
||||
("latest", "manifest.json"),
|
||||
("latest", "manifest.sig"),
|
||||
("1.2.3", "tar.zst"),
|
||||
][index];
|
||||
assert_eq!(
|
||||
request,
|
||||
format!(
|
||||
"GET /releases/{selection}/dever-{}.{suffix} HTTP/1.1\r\n",
|
||||
platform_identity()
|
||||
)
|
||||
);
|
||||
assert_eq!(request, format!("GET {path} HTTP/1.1\r\n"));
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
@@ -231,7 +341,123 @@ fn server(responses: Vec<Vec<u8>>) -> (ReleaseSource, std::thread::JoinHandle<()
|
||||
stream.write_all(&response).unwrap();
|
||||
}
|
||||
});
|
||||
(ReleaseSource::new(base), worker)
|
||||
(ReleaseSource::new(format!("{base}/releases")), worker)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_and_extensions_share_one_release_snapshot() {
|
||||
use super::super::{BuildTarget, ExtensionId, ExtensionKind, ExtensionSource};
|
||||
use crate::libs::Ecosystem;
|
||||
let extension = ExtensionId {
|
||||
kind: ExtensionKind::Runtime(Ecosystem::Pip),
|
||||
target: BuildTarget::LinuxX86_64,
|
||||
};
|
||||
let (source, worker) = serve(|base| {
|
||||
vec![
|
||||
(
|
||||
"/releases/latest".into(),
|
||||
release_metadata(base, "v1.2.3", &[(&extension.asset_suffix(), b"extension")]),
|
||||
),
|
||||
("/attachments/0".into(), b"extension".to_vec()),
|
||||
("/attachments/0".into(), b"extension".to_vec()),
|
||||
]
|
||||
});
|
||||
let selected = source.resolve("latest", Instant::now()).unwrap();
|
||||
assert!(Rc::ptr_eq(
|
||||
&selected,
|
||||
&source.resolve("latest", Instant::now()).unwrap()
|
||||
));
|
||||
assert!(Rc::ptr_eq(
|
||||
&selected,
|
||||
&source.resolve("1.2.3", Instant::now()).unwrap()
|
||||
));
|
||||
for _ in 0..2 {
|
||||
let mut contents = Vec::new();
|
||||
source
|
||||
.open(&selected.version, &extension)
|
||||
.unwrap()
|
||||
.read_to_end(&mut contents)
|
||||
.unwrap();
|
||||
assert_eq!(contents, b"extension");
|
||||
}
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_versions_resolve_the_tag_endpoint_and_missing_or_foreign_assets_fail() {
|
||||
let (source, worker) = serve(|base| {
|
||||
vec![(
|
||||
"/releases/tags/v1.2.3".into(),
|
||||
release_metadata(base, "v1.2.3", &[("tar.zst", b"archive")]),
|
||||
)]
|
||||
});
|
||||
let selected = source.resolve("1.2.3", Instant::now()).unwrap();
|
||||
worker.join().unwrap();
|
||||
assert!(selected.asset("manifest.sig").is_err());
|
||||
let foreign = ReleaseAsset {
|
||||
name: "asset".into(),
|
||||
browser_download_url: "https://evil.example/archive".into(),
|
||||
size: 7,
|
||||
};
|
||||
assert!(
|
||||
source
|
||||
.open_asset(&foreign, Instant::now())
|
||||
.err()
|
||||
.unwrap()
|
||||
.contains("origin")
|
||||
);
|
||||
for version in ["../latest", "v1.2.3", "1.2.3-rc.1"] {
|
||||
assert!(source.resolve(version, Instant::now()).is_err());
|
||||
}
|
||||
let (source, worker) = serve(|_| {
|
||||
vec![(
|
||||
"/releases/latest".into(),
|
||||
release_metadata("https://evil.example", "v1.2.3", &[("tar.zst", b"archive")]),
|
||||
)]
|
||||
});
|
||||
assert!(
|
||||
source
|
||||
.resolve("latest", Instant::now())
|
||||
.err()
|
||||
.unwrap()
|
||||
.contains("origin")
|
||||
);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_metadata_downloads_are_bounded_and_asset_lengths_are_checked() {
|
||||
let (source, worker) = serve(|_| {
|
||||
vec![(
|
||||
"/releases/latest".into(),
|
||||
vec![b' '; METADATA_LIMIT as usize + 1],
|
||||
)]
|
||||
});
|
||||
assert!(
|
||||
source
|
||||
.resolve("latest", Instant::now())
|
||||
.err()
|
||||
.unwrap()
|
||||
.contains("size limit")
|
||||
);
|
||||
worker.join().unwrap();
|
||||
let root = Download::new(&std::env::temp_dir()).unwrap();
|
||||
for size in [2, 4] {
|
||||
let (source, worker) = serve(|_| vec![("/attachments/manifest".into(), b"abc".to_vec())]);
|
||||
let asset = ReleaseAsset {
|
||||
name: "asset".into(),
|
||||
browser_download_url: source.base.join("/attachments/manifest").unwrap().into(),
|
||||
size,
|
||||
};
|
||||
assert!(
|
||||
source
|
||||
.metadata(&asset, &root.0.join("manifest.json"), Instant::now())
|
||||
.unwrap_err()
|
||||
.contains("size")
|
||||
);
|
||||
worker.join().unwrap();
|
||||
assert!(!root.0.join("manifest.json").exists());
|
||||
}
|
||||
}
|
||||
|
||||
fn compressed_release(core: &[u8]) -> Vec<u8> {
|
||||
@@ -396,6 +622,31 @@ fn explicit_download_verifies_the_complete_bundle_before_publishing_catalog() {
|
||||
assert_eq!(fs::read_dir(layout.staging()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_manifest_must_match_the_selected_tag_before_publication() {
|
||||
let root = Download::new(&std::env::temp_dir()).unwrap();
|
||||
let layout = Layout::new(root.0.join("machine"));
|
||||
layout.initialize().unwrap();
|
||||
let (_, bytes, signature, key) = signed_release();
|
||||
fs::write(
|
||||
layout.state().join("trusted-release-key"),
|
||||
super::super::release::hex(&key),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(layout.downloads().join("latest"), "0.9.0\n").unwrap();
|
||||
let (source, worker) = release_server("latest", "v1.2.4", vec![bytes, signature]);
|
||||
let error = prepare_from(&layout, "latest", &source).unwrap_err();
|
||||
worker.join().unwrap();
|
||||
assert!(error.contains("selected release tag"), "{error}");
|
||||
assert_eq!(
|
||||
fs::read_to_string(layout.downloads().join("latest")).unwrap(),
|
||||
"0.9.0\n"
|
||||
);
|
||||
assert!(!layout.downloads().join("1.2.3").exists());
|
||||
assert!(!layout.downloads().join("1.2.4").exists());
|
||||
assert_eq!(fs::read_dir(layout.staging()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_signatures_and_corrupt_payloads_preserve_the_previous_catalog_and_active_version() {
|
||||
for bad_signature in [true, false] {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
public type UserStatus {
|
||||
Pending
|
||||
Active
|
||||
Disabled(reason: Text)
|
||||
}
|
||||
|
||||
public type CreateInput {
|
||||
id: Id
|
||||
name: Text
|
||||
email: Text
|
||||
phone: Text?
|
||||
score: Int
|
||||
labels: Map<Text, Text>
|
||||
}
|
||||
|
||||
public type CreateResult {
|
||||
Created(user: User)
|
||||
InvalidName(reason: Text)
|
||||
InvalidEmail(reason: Text)
|
||||
}
|
||||
|
||||
public type User {
|
||||
id: Id
|
||||
name: Text
|
||||
email: Text
|
||||
phone: Text?
|
||||
score: Int
|
||||
labels: Map<Text, Text>
|
||||
status: UserStatus
|
||||
}
|
||||
|
||||
public type UserView {
|
||||
id: Id
|
||||
name: Text
|
||||
email: Text
|
||||
phone: Text
|
||||
score: Int
|
||||
can_sign_in: Bool
|
||||
}
|
||||
|
||||
public create(input: CreateInput) (result: CreateResult) {
|
||||
normalized = normalize(input)
|
||||
result = create_name(normalized, normalized.name)
|
||||
}
|
||||
|
||||
normalize(input: CreateInput) (result: CreateInput) {
|
||||
result = input
|
||||
result.name = text.trim(input.name)
|
||||
result.email = text.lower(text.trim(input.email))
|
||||
}
|
||||
|
||||
create_name(input: CreateInput, name: "") (result: CreateResult) {
|
||||
result = CreateResult.InvalidName("name is required")
|
||||
}
|
||||
|
||||
create_name(input: CreateInput, name: other) (result: CreateResult) {
|
||||
result = create_email(input, name, input.email)
|
||||
}
|
||||
|
||||
create_email(input: CreateInput, name: Text, email: "") (result: CreateResult) {
|
||||
result = CreateResult.InvalidEmail("email is required")
|
||||
}
|
||||
|
||||
create_email(input: CreateInput, name: Text, email: other) (result: CreateResult) {
|
||||
user = User {
|
||||
id = input.id
|
||||
name = name
|
||||
email = email
|
||||
phone = input.phone
|
||||
score = input.score
|
||||
labels = input.labels
|
||||
status = UserStatus.Pending
|
||||
}
|
||||
result = CreateResult.Created(user)
|
||||
}
|
||||
|
||||
public activate(user: User) (result: User) {
|
||||
result = user
|
||||
result.status = UserStatus.Active
|
||||
}
|
||||
|
||||
public disable(user: User, reason: Text) (result: User) {
|
||||
result = user
|
||||
result.status = UserStatus.Disabled(reason)
|
||||
}
|
||||
|
||||
public can_sign_in(status: UserStatus.Active) (allowed: Bool) {
|
||||
allowed = true
|
||||
}
|
||||
|
||||
public can_sign_in(status: other) (allowed: Bool) {
|
||||
allowed = false
|
||||
}
|
||||
|
||||
phone_text(phone: Text) (result: Text) {
|
||||
result = phone
|
||||
}
|
||||
|
||||
phone_text(phone: null) (result: Text) {
|
||||
result = "Not provided"
|
||||
}
|
||||
|
||||
public to_view(user: User) (view: UserView) {
|
||||
view = UserView {
|
||||
id = user.id
|
||||
name = user.name
|
||||
email = user.email
|
||||
phone = phone_text(user.phone)
|
||||
score = user.score
|
||||
can_sign_in = can_sign_in(user.status)
|
||||
}
|
||||
}
|
||||
|
||||
public to_views(users: List<User>) (views: List<UserView>) {
|
||||
views = each(to_view, users)
|
||||
}
|
||||
|
||||
is_active(user: User) (result: Bool) {
|
||||
result = can_sign_in(user.status)
|
||||
}
|
||||
|
||||
public active_views(users: List<User>) (views: List<UserView>) {
|
||||
active = filter(is_active, users)
|
||||
views = each(to_view, active)
|
||||
}
|
||||
|
||||
has_id(user: User, expected: Id) (matches: Bool) {
|
||||
matches = user.id == expected
|
||||
}
|
||||
|
||||
public find_user(users: List<User>, id: Id) (user: User?) {
|
||||
user = find(has_id, users, id)
|
||||
}
|
||||
|
||||
public put_label(user: User, key: Text, value: Text) (result: User) {
|
||||
result = user
|
||||
result.labels = put(user.labels, key, value)
|
||||
}
|
||||
|
||||
public label(user: User, key: Text) (value: Text?) {
|
||||
value = get(user.labels, key)
|
||||
}
|
||||
|
||||
score_bonus(user: User, rate: Decimal) (amount: Decimal) {
|
||||
amount = user.score * rate
|
||||
}
|
||||
|
||||
public total_score_bonus(users: List<User>, rate: Decimal) (total: Decimal) {
|
||||
total = sum(score_bonus, users, rate)
|
||||
}
|
||||
@@ -4,17 +4,7 @@ use support::{checked, rejected, sources};
|
||||
|
||||
#[test]
|
||||
fn full_language_contract_package_checks_with_the_official_sources() {
|
||||
let prd = include_str!("../../../.trellis/tasks/09-04-dever-language-mvp/prd.md");
|
||||
let source = prd
|
||||
.split_once("## Complete Package Example\n")
|
||||
.unwrap()
|
||||
.1
|
||||
.split_once("```dever\n")
|
||||
.unwrap()
|
||||
.1
|
||||
.split_once("\n```")
|
||||
.unwrap()
|
||||
.0;
|
||||
let source = include_str!("../fixtures/core/complete_package.dever");
|
||||
let mut sources = dever_core::source::SourceMap::default();
|
||||
sources.add("account/user.dever", source);
|
||||
checked(&sources);
|
||||
|
||||
@@ -189,10 +189,7 @@ fn current_examples_official_packages_and_complete_contract_roundtrip() {
|
||||
assert_roundtrip_at(&path, source.text());
|
||||
}
|
||||
}
|
||||
let prd = include_str!("../../../.trellis/tasks/09-04-dever-language-mvp/prd.md");
|
||||
let (_, example) = prd.split_once("## Complete Package Example\n").unwrap();
|
||||
let (_, fenced) = example.split_once("```dever\n").unwrap();
|
||||
assert_roundtrip(fenced.split_once("\n```").unwrap().0);
|
||||
assert_roundtrip(include_str!("../fixtures/core/complete_package.dever"));
|
||||
}
|
||||
|
||||
fn source_root_relative(path: &FilePath) -> PathBuf {
|
||||
|
||||
@@ -47,10 +47,7 @@ fn assigned(expression: &str) -> Expression {
|
||||
|
||||
#[test]
|
||||
fn parses_the_complete_package_from_the_product_contract() {
|
||||
let prd = include_str!("../../../.trellis/tasks/09-04-dever-language-mvp/prd.md");
|
||||
let (_, example_section) = prd.split_once("## Complete Package Example\n").unwrap();
|
||||
let (_, fenced) = example_section.split_once("```dever\n").unwrap();
|
||||
let (source, _) = fenced.split_once("\n```").unwrap();
|
||||
let source = include_str!("../fixtures/core/complete_package.dever");
|
||||
let package = parsed(source);
|
||||
assert_eq!(
|
||||
package
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createRequire } = require('node:module');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const extension = path.join(root, 'editor/vscode');
|
||||
const extensionRequire = createRequire(path.join(extension, 'package.json'));
|
||||
const textmate = extensionRequire('vscode-textmate');
|
||||
const oniguruma = extensionRequire('vscode-oniguruma');
|
||||
const cliArguments = process.argv.slice(2);
|
||||
assert.equal(cliArguments.length, 2, 'pass --markdown-grammar followed by the real VS Code grammar path');
|
||||
assert.equal(cliArguments[0], '--markdown-grammar');
|
||||
const markdownPath = path.resolve(cliArguments[1]);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(extension, 'package.json'), 'utf8'));
|
||||
const configuration = JSON.parse(fs.readFileSync(path.join(extension, 'language-configuration.json'), 'utf8'));
|
||||
|
||||
async function loadGrammars() {
|
||||
const wasm = fs.readFileSync(extensionRequire.resolve('vscode-oniguruma/release/onig.wasm'));
|
||||
await oniguruma.loadWASM(wasm.buffer.slice(wasm.byteOffset, wasm.byteOffset + wasm.byteLength));
|
||||
const catalog = new Map();
|
||||
for (const contribution of manifest.contributes.grammars) {
|
||||
const filename = path.join(extension, contribution.path);
|
||||
catalog.set(contribution.scopeName, textmate.parseRawGrammar(fs.readFileSync(filename, 'utf8'), filename));
|
||||
}
|
||||
const markdown = textmate.parseRawGrammar(fs.readFileSync(markdownPath, 'utf8'), markdownPath);
|
||||
assert.equal(markdown.scopeName, 'text.html.markdown');
|
||||
catalog.set(markdown.scopeName, markdown);
|
||||
const registry = new textmate.Registry({
|
||||
onigLib: Promise.resolve({
|
||||
createOnigScanner: patterns => new oniguruma.OnigScanner(patterns),
|
||||
createOnigString: value => new oniguruma.OnigString(value)
|
||||
}),
|
||||
loadGrammar: scope => catalog.get(scope) || null,
|
||||
getInjections: scope => manifest.contributes.grammars
|
||||
.filter(contribution => contribution.injectTo?.includes(scope))
|
||||
.map(contribution => contribution.scopeName)
|
||||
});
|
||||
return {
|
||||
source: await registry.loadGrammarWithConfiguration('source.dever', 1, {}),
|
||||
markdown: await registry.loadGrammarWithConfiguration('text.html.markdown', 2, {
|
||||
embeddedLanguages: { 'meta.embedded.block.dever': 1 },
|
||||
tokenTypes: { 'meta.embedded.block.dever': 0 }
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const grammars = loadGrammars();
|
||||
|
||||
function tokenize(grammar, document) {
|
||||
let state = textmate.INITIAL;
|
||||
return document.split(/\r\n|\r|\n/).map(line => {
|
||||
const encoded = grammar.tokenizeLine2(line, state);
|
||||
const result = grammar.tokenizeLine(line, state);
|
||||
state = result.ruleStack;
|
||||
return { line, tokens: result.tokens, encoded: encoded.tokens };
|
||||
});
|
||||
}
|
||||
|
||||
function tokenAt(rows, line, fragment) {
|
||||
const row = rows[line];
|
||||
const offset = row.line.indexOf(fragment);
|
||||
assert.ok(offset >= 0, `line ${line + 1} does not contain ${fragment}`);
|
||||
const token = row.tokens.find(candidate => candidate.startIndex <= offset && candidate.endIndex > offset);
|
||||
assert.ok(token, `no token for ${fragment} on line ${line + 1}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
function hasScope(rows, line, fragment, scope) {
|
||||
const actual = tokenAt(rows, line, fragment).scopes;
|
||||
assert.ok(actual.includes(scope), `${fragment}: expected ${scope}, received ${actual.join(' ')}`);
|
||||
}
|
||||
|
||||
function sourceFiles(directory, suffix) {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
||||
const filename = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? sourceFiles(filename, suffix) : filename.endsWith(suffix) ? [filename] : [];
|
||||
});
|
||||
}
|
||||
|
||||
test('types, fields and named function inputs/outputs receive Dever scopes', async () => {
|
||||
const rows = tokenize((await grammars).source, [
|
||||
'type Profile {',
|
||||
' id: Int?',
|
||||
' name: Text',
|
||||
'}',
|
||||
'greet(name: Text) (message: Text) {',
|
||||
' message = app.format(name)',
|
||||
'}'
|
||||
].join('\n'));
|
||||
hasScope(rows, 0, 'type', 'storage.type.dever');
|
||||
hasScope(rows, 0, 'Profile', 'entity.name.type.dever');
|
||||
hasScope(rows, 1, 'id', 'variable.other.member.dever');
|
||||
hasScope(rows, 1, 'Int', 'support.type.dever');
|
||||
hasScope(rows, 4, 'greet', 'entity.name.function.dever');
|
||||
hasScope(rows, 4, 'name', 'variable.other.member.dever');
|
||||
hasScope(rows, 4, 'message', 'variable.other.member.dever');
|
||||
hasScope(rows, 5, 'app', 'entity.name.namespace.dever');
|
||||
hasScope(rows, 5, 'format', 'entity.name.function.dever');
|
||||
});
|
||||
|
||||
test('API, Model, Job and external Adapter declarations are covered', async () => {
|
||||
const source = (await grammars).source;
|
||||
for (const method of ['get', 'post', 'put', 'delete', 'cmd', 'rest']) {
|
||||
const rows = tokenize(source, `${method} profile = app.profile`);
|
||||
hasScope(rows, 0, method, 'keyword.control.api.dever');
|
||||
}
|
||||
const rows = tokenize(source, [
|
||||
'public get profile = app.profile',
|
||||
' private owner user_id: user.account.model.id = dever.auth.user_id()',
|
||||
'index(user_id, created_at)',
|
||||
'sql owned_articles(id: Int) (articles: List<Article>) {',
|
||||
'job send(message: Text) () fails app.Error {',
|
||||
'schedule send_every_minute {',
|
||||
'external npm "worker.js" {}'
|
||||
].join('\n'));
|
||||
hasScope(rows, 0, 'public', 'storage.modifier.dever');
|
||||
hasScope(rows, 1, 'owner', 'storage.modifier.dever');
|
||||
hasScope(rows, 2, 'index', 'keyword.other.declaration.dever');
|
||||
hasScope(rows, 3, 'sql', 'keyword.other.declaration.dever');
|
||||
hasScope(rows, 4, 'job', 'storage.modifier.dever');
|
||||
hasScope(rows, 4, 'fails', 'storage.modifier.dever');
|
||||
hasScope(rows, 5, 'schedule', 'keyword.other.declaration.dever');
|
||||
hasScope(rows, 6, 'npm', 'keyword.other.external.dever');
|
||||
});
|
||||
|
||||
test('hash comments, quoted text and escapes do not leak into subsequent lines', async () => {
|
||||
const rows = tokenize((await grammars).source, [
|
||||
'# type Fake and return',
|
||||
String.raw`message = "中文 # and type \n \u{4E2D} \""`,
|
||||
String.raw`invalid = "\q"`,
|
||||
'broken = "unfinished',
|
||||
'type Next { value: Text }'
|
||||
].join('\n'));
|
||||
hasScope(rows, 0, 'type', 'comment.line.number-sign.dever');
|
||||
hasScope(rows, 1, '#', 'string.quoted.double.dever');
|
||||
hasScope(rows, 1, '\\n', 'constant.character.escape.dever');
|
||||
hasScope(rows, 1, '\\u{4E2D}', 'constant.character.escape.dever');
|
||||
hasScope(rows, 2, '\\q', 'invalid.illegal.escape.dever');
|
||||
hasScope(rows, 4, 'type', 'storage.type.dever');
|
||||
assert.ok(!tokenAt(rows, 4, 'type').scopes.includes('string.quoted.double.dever'));
|
||||
});
|
||||
|
||||
test('integer division is an operator, not a JavaScript line comment', async () => {
|
||||
const rows = tokenize((await grammars).source, 'count = 12 // 3\namount = 1.25e-2\nvalid = true and not false');
|
||||
hasScope(rows, 0, '//', 'keyword.operator.dever');
|
||||
hasScope(rows, 0, '3', 'constant.numeric.dever');
|
||||
assert.ok(!tokenAt(rows, 0, '3').scopes.some(scope => scope.startsWith('comment.')));
|
||||
hasScope(rows, 1, '1.25e-2', 'constant.numeric.dever');
|
||||
hasScope(rows, 2, 'true', 'constant.language.dever');
|
||||
hasScope(rows, 2, 'and', 'keyword.operator.logical.dever');
|
||||
});
|
||||
|
||||
test('excluded control-flow syntax is not presented as a valid function', async () => {
|
||||
const rows = tokenize((await grammars).source, 'if(value)\nreturn value\nimport other\npackage main');
|
||||
for (const [line, keyword] of ['if', 'return', 'import', 'package'].entries()) {
|
||||
hasScope(rows, line, keyword, 'invalid.illegal.keyword.dever');
|
||||
}
|
||||
});
|
||||
|
||||
test('Markdown fences reuse source.dever while prose keeps Markdown scopes', async () => {
|
||||
for (const marker of ['```', '~~~']) {
|
||||
for (const language of ['dever', 'typescript dever']) {
|
||||
const rows = tokenize((await grammars).markdown, [
|
||||
'# 文档', '', '## 类型', `${marker}${language}`,
|
||||
'type Profile { name: Text }', marker, '', '正文 type Profile'
|
||||
].join('\n'));
|
||||
hasScope(rows, 4, 'type', 'storage.type.dever');
|
||||
hasScope(rows, 4, 'Text', 'support.type.dever');
|
||||
hasScope(rows, 4, 'Profile', 'meta.embedded.block.dever');
|
||||
assert.ok(!tokenAt(rows, 7, 'type').scopes.includes('source.dever'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('embedded tokens carry the Dever language ID and are not string tokens', async () => {
|
||||
const rows = tokenize((await grammars).markdown, '```dever\ntype Profile {}\n```');
|
||||
const metadata = rows[1].encoded[1];
|
||||
assert.equal(metadata & 0xff, 1);
|
||||
assert.equal((metadata >>> 8) & 3, 0);
|
||||
});
|
||||
|
||||
test('fence closing honors its character and minimum opening length', async () => {
|
||||
const rows = tokenize((await grammars).markdown, [
|
||||
'````dever', 'type First {}', '```', 'type Second {}',
|
||||
'~~~~', 'type Third {}', '`````', '正文 type Outside'
|
||||
].join('\n'));
|
||||
for (const line of [1, 3, 5]) hasScope(rows, line, 'type', 'storage.type.dever');
|
||||
assert.ok(!tokenAt(rows, 7, 'type').scopes.includes('source.dever'));
|
||||
const waves = tokenize((await grammars).markdown, '~~~dever\ntype Inside {}\n~~~~\n正文 type Outside');
|
||||
assert.ok(!tokenAt(waves, 3, 'type').scopes.includes('source.dever'));
|
||||
});
|
||||
|
||||
test('valid indentation and CRLF work without taking over other language fences', async () => {
|
||||
const rows = tokenize((await grammars).markdown, ' ```dever\r\ntype Profile {}\r\n ```\r\n正文');
|
||||
hasScope(rows, 1, 'type', 'storage.type.dever');
|
||||
for (const language of ['typescript', 'javascript', 'dever extra', 'typescript dever']) {
|
||||
const other = tokenize((await grammars).markdown, `\`\`\`${language}\ntype Profile {}\n\`\`\``);
|
||||
assert.ok(!tokenAt(other, 1, 'type').scopes.includes('source.dever'), language);
|
||||
}
|
||||
});
|
||||
|
||||
test('quotes, lists, showcase fences, indented code and comments retain Markdown scopes', async () => {
|
||||
for (const [document, line] of [
|
||||
['> ```dever\n> type Quoted {}\n> ```', 1],
|
||||
['- 示例\n\n ```dever\n type Nested {}\n ```', 3],
|
||||
['````markdown\n```dever\ntype Showcase {}\n```\n````', 2],
|
||||
[' ```dever\n type Indented {}\n ```', 1],
|
||||
['<!--\n```dever\ntype Comment {}\n```\n-->', 2]
|
||||
]) {
|
||||
const rows = tokenize((await grammars).markdown, document);
|
||||
assert.ok(!tokenAt(rows, line, 'type').scopes.includes('source.dever'), document);
|
||||
}
|
||||
});
|
||||
|
||||
test('language configuration uses Dever comments, pairs and indentation', () => {
|
||||
assert.equal(configuration.comments.lineComment, '#');
|
||||
assert.equal(configuration.comments.blockComment, undefined);
|
||||
assert.deepEqual(configuration.brackets, [['{', '}'], ['[', ']'], ['(', ')']]);
|
||||
const increase = new RegExp(configuration.indentationRules.increaseIndentPattern);
|
||||
const decrease = new RegExp(configuration.indentationRules.decreaseIndentPattern);
|
||||
for (const line of ['greet(name: Text) (message: Text) {', ' values = [', 'type Profile { # 字段']) {
|
||||
assert.ok(increase.test(line), line);
|
||||
}
|
||||
for (const line of ['# example {', 'message = "literal {"', 'message = "value" # {']) {
|
||||
assert.ok(!increase.test(line), line);
|
||||
}
|
||||
assert.ok(decrease.test(' }'));
|
||||
assert.ok(decrease.test(' ]'));
|
||||
assert.ok(configuration.autoClosingPairs.every(pair => pair.notIn.includes('string') && pair.notIn.includes('comment')));
|
||||
assert.deepEqual(manifest.contributes.languages[0].extensions, ['.dever']);
|
||||
const injection = manifest.contributes.grammars.find(contribution => contribution.injectTo?.includes('text.html.markdown'));
|
||||
assert.deepEqual(injection.embeddedLanguages, { 'meta.embedded.block.dever': 'dever' });
|
||||
assert.deepEqual(injection.tokenTypes, { 'meta.embedded.block.dever': 'other' });
|
||||
assert.equal(manifest.main, undefined);
|
||||
assert.equal(manifest.dependencies, undefined);
|
||||
});
|
||||
|
||||
test('maintained plain and Markdown CMS files tokenize without illegal tokens', async () => {
|
||||
const loaded = await grammars;
|
||||
for (const [directory, suffix, grammar] of [
|
||||
['examples/cms/dever', '.dever', loaded.source],
|
||||
['examples/cms/md', '.dever.md', loaded.markdown]
|
||||
]) {
|
||||
const files = sourceFiles(path.join(root, directory), suffix);
|
||||
assert.ok(files.length > 0, directory);
|
||||
let highlighted = 0;
|
||||
for (const filename of files) {
|
||||
const rows = tokenize(grammar, fs.readFileSync(filename, 'utf8'));
|
||||
let fileHighlighted = 0;
|
||||
for (const row of rows) {
|
||||
for (const token of row.tokens) {
|
||||
assert.ok(!token.scopes.some(scope => scope.startsWith('invalid.illegal.')), `${filename}: ${row.line}`);
|
||||
if (token.scopes.includes('source.dever')) fileHighlighted += 1;
|
||||
}
|
||||
}
|
||||
assert.ok(fileHighlighted > 0, `${filename} must actually enter the Dever grammar`);
|
||||
highlighted += fileHighlighted;
|
||||
}
|
||||
assert.ok(highlighted > files.length, `${directory} must actually enter the Dever grammar`);
|
||||
process.stdout.write(`CMS ${directory}: ${files.length} files, ${highlighted} Dever tokens\n`);
|
||||
}
|
||||
});
|
||||
@@ -88,6 +88,15 @@ class InstallerTests(unittest.TestCase):
|
||||
def create_assets(self, output):
|
||||
return assets.create(self.release, output, Path("/usr/bin/zstd"))
|
||||
|
||||
def release_record(self, directory, version="1.2.3"):
|
||||
return {
|
||||
"tag_name": f"v{version}", "draft": False, "prerelease": False,
|
||||
"assets": [{"name": path.name, "size": path.stat().st_size,
|
||||
"browser_download_url":
|
||||
f"https://git.dever.cc/shemic/dever/releases/download/v{version}/{path.name}"}
|
||||
for path in sorted(directory.iterdir())],
|
||||
}
|
||||
|
||||
def archive_payloads(self, path):
|
||||
contents = subprocess.run(["/usr/bin/zstd", "-q", "-d", "-c", str(path)],
|
||||
env={}, capture_output=True, check=True, timeout=15).stdout
|
||||
@@ -212,10 +221,14 @@ class InstallerTests(unittest.TestCase):
|
||||
assets_path = self.root / "assets"
|
||||
self.create_assets(assets_path)
|
||||
calls = []
|
||||
record = self.release_record(assets_path)
|
||||
with mock.patch.object(installer, "download", return_value=io.BytesIO(json.dumps(record).encode())):
|
||||
version, attachments = installer.resolve_release("latest")
|
||||
self.assertEqual(version, "1.2.3")
|
||||
|
||||
def download(address):
|
||||
self.assertTrue(address.startswith(
|
||||
f"https://git.dever.cc/shemic/dever/dist/1.2.3/dever-{self.platform}."))
|
||||
f"https://git.dever.cc/shemic/dever/releases/download/v1.2.3/dever-{self.platform}."))
|
||||
return (assets_path / address.rsplit("/", 1)[-1]).open("rb")
|
||||
|
||||
def extract(command, **kwargs):
|
||||
@@ -233,8 +246,8 @@ class InstallerTests(unittest.TestCase):
|
||||
|
||||
with mock.patch.object(installer, "download", side_effect=download), \
|
||||
mock.patch.object(installer.subprocess, "run", side_effect=extract):
|
||||
installer.payload(self.directory("network"), None, "1.2.3", self.platform,
|
||||
self.catalog, self.public)
|
||||
installer.payload(self.directory("network"), None, self.platform,
|
||||
self.catalog, self.public, attachments)
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(list(self.root.glob(".extract-*")), [])
|
||||
|
||||
@@ -244,7 +257,7 @@ class InstallerTests(unittest.TestCase):
|
||||
with mock.patch.object(installer, "download", side_effect=download), \
|
||||
mock.patch.object(installer.subprocess, "run") as execute, \
|
||||
self.assertRaises(ValueError):
|
||||
installer.payload(self.release, None, "1.2.3", self.platform, self.catalog, self.public)
|
||||
installer.payload(self.release, None, self.platform, self.catalog, self.public, attachments)
|
||||
execute.assert_not_called()
|
||||
|
||||
def test_download_archive_is_bounded(self):
|
||||
@@ -255,19 +268,19 @@ class InstallerTests(unittest.TestCase):
|
||||
|
||||
def test_local_payload_rejects_symlink_and_source_directory(self):
|
||||
copied = self.directory("copied")
|
||||
installer.payload(copied, self.release, "1.2.3", self.platform, self.catalog)
|
||||
installer.payload(copied, self.release, self.platform, self.catalog)
|
||||
self.assertEqual((copied / "dever-core").read_bytes(), self.payloads["dever-core"])
|
||||
core = self.release / "dever-core"
|
||||
core.unlink()
|
||||
core.symlink_to(self.public)
|
||||
with self.assertRaisesRegex(ValueError, "符号链接"):
|
||||
installer.payload(self.directory("symlink"), self.release, "1.2.3", self.platform, self.catalog)
|
||||
installer.payload(self.directory("symlink"), self.release, self.platform, self.catalog)
|
||||
with self.assertRaisesRegex(ValueError, "symlinks"):
|
||||
self.create_assets(self.root / "invalid-assets")
|
||||
core.unlink()
|
||||
core.mkdir()
|
||||
with self.assertRaisesRegex(ValueError, "类型"):
|
||||
installer.payload(self.directory("directory"), self.release, "1.2.3", self.platform, self.catalog)
|
||||
installer.payload(self.directory("directory"), self.release, self.platform, self.catalog)
|
||||
|
||||
def test_image_install_authenticates_before_mock_bootstrap_and_cleans_owned_staging(self):
|
||||
image = self.directory("image")
|
||||
@@ -302,27 +315,83 @@ class InstallerTests(unittest.TestCase):
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(list(image.iterdir()), [])
|
||||
|
||||
def test_metadata_uses_latest_or_exact_directory_and_never_a_github_fallback(self):
|
||||
for version in ("latest", "1.2.3"):
|
||||
destination = self.directory(version)
|
||||
def test_metadata_pins_latest_or_exact_release_before_downloading_matching_assets(self):
|
||||
assets_path = self.root / "assets"
|
||||
self.create_assets(assets_path)
|
||||
record = self.release_record(assets_path)
|
||||
for requested, endpoint in (("latest", "latest"), ("1.2.3", "tags/v1.2.3")):
|
||||
destination = self.directory(requested)
|
||||
addresses = []
|
||||
|
||||
def download(address):
|
||||
addresses.append(address)
|
||||
return io.BytesIO(b"metadata")
|
||||
if address == f"{installer.RELEASES}/{endpoint}":
|
||||
return io.BytesIO(json.dumps(record).encode())
|
||||
return (assets_path / address.rsplit("/", 1)[-1]).open("rb")
|
||||
|
||||
with mock.patch.object(installer, "download", side_effect=download):
|
||||
installer.metadata(destination, None, version, self.platform)
|
||||
self.assertEqual(addresses, [
|
||||
f"https://git.dever.cc/shemic/dever/dist/{version}/dever-{self.platform}.{suffix}"
|
||||
selected, attachments = installer.resolve_release(requested)
|
||||
installer.metadata(destination, None, attachments, self.platform)
|
||||
authenticated, _, _ = installer.authenticate(destination, self.public, self.scratch,
|
||||
self.platform, selected)
|
||||
self.assertEqual(authenticated, "1.2.3")
|
||||
self.assertEqual(addresses, [f"{installer.RELEASES}/{endpoint}"] + [
|
||||
f"https://git.dever.cc/shemic/dever/releases/download/v1.2.3/dever-{self.platform}.{suffix}"
|
||||
for suffix in ("manifest.json", "manifest.sig")
|
||||
])
|
||||
|
||||
def test_release_metadata_rejects_unpublished_ambiguous_and_foreign_assets(self):
|
||||
directory = self.root / "assets"
|
||||
self.create_assets(directory)
|
||||
valid = self.release_record(directory)
|
||||
mutations = [
|
||||
lambda record: record.update(draft=True),
|
||||
lambda record: record.update(prerelease=True),
|
||||
lambda record: record.update(tag_name="v01.2.3"),
|
||||
lambda record: record.update(tag_name="v1.2.4"),
|
||||
lambda record: record["assets"].append(record["assets"][0]),
|
||||
lambda record: record["assets"][0].update(size=True),
|
||||
lambda record: record["assets"][0].update(browser_download_url="https://evil.example/asset"),
|
||||
lambda record: record.update(assets=[]),
|
||||
lambda record: record.update(assets=[record["assets"][0]] * 513),
|
||||
]
|
||||
for mutate in mutations:
|
||||
record = json.loads(json.dumps(valid))
|
||||
mutate(record)
|
||||
with self.subTest(record=record["tag_name"]), \
|
||||
mock.patch.object(installer, "download", return_value=io.BytesIO(json.dumps(record).encode())), \
|
||||
self.assertRaises(ValueError):
|
||||
installer.resolve_release("1.2.3")
|
||||
with mock.patch.object(installer, "MAX_METADATA", 10), \
|
||||
mock.patch.object(installer, "download", return_value=io.BytesIO(b" " * 11)), \
|
||||
self.assertRaisesRegex(ValueError, "大小限制"):
|
||||
installer.resolve_release("latest")
|
||||
|
||||
def test_latest_tag_must_match_signed_manifest_and_required_assets_must_exist(self):
|
||||
directory = self.root / "assets"
|
||||
self.create_assets(directory)
|
||||
record = self.release_record(directory, "1.2.4")
|
||||
with mock.patch.object(installer, "download", return_value=io.BytesIO(json.dumps(record).encode())):
|
||||
selected, attachments = installer.resolve_release("latest")
|
||||
with self.assertRaisesRegex(ValueError, "版本与请求不匹配"):
|
||||
installer.authenticate(self.release, self.public, self.scratch, self.platform, selected)
|
||||
with self.assertRaisesRegex(ValueError, "缺少附件"):
|
||||
installer.asset(attachments, self.platform, "missing.tar.zst")
|
||||
with self.assertRaisesRegex(ValueError, "大小限制"):
|
||||
installer.asset(attachments, self.platform, "manifest.json", 1)
|
||||
|
||||
def test_metadata_rejects_truncated_api_attachment(self):
|
||||
name = f"dever-{self.platform}.manifest.json"
|
||||
attachments = {name: {"address": "https://git.dever.cc/asset", "bytes": 20}}
|
||||
with mock.patch.object(installer, "download", return_value=io.BytesIO(b"short")), \
|
||||
self.assertRaisesRegex(ValueError, "长度不匹配"):
|
||||
installer.metadata(self.directory("truncated"), None, attachments, self.platform)
|
||||
|
||||
def test_redirects_remain_on_the_official_https_site(self):
|
||||
handler = installer.OfficialRedirect()
|
||||
current = "https://git.dever.cc/shemic/dever/dist/latest/a"
|
||||
current = "https://git.dever.cc/shemic/dever/releases/download/v1.2.3/a"
|
||||
request = installer.urllib.request.Request(current)
|
||||
allowed = "https://git.dever.cc/shemic/dever/dist/1.2.3/a"
|
||||
allowed = "https://git.dever.cc/attachments/fixture"
|
||||
redirected = handler.redirect_request(request, None, 302, "Found", {}, allowed)
|
||||
self.assertEqual(redirected.full_url, allowed)
|
||||
for address in (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -198,18 +199,20 @@ def install_assets(assets, image, manifest):
|
||||
# Only transport is local: signature verification, full tar extraction and
|
||||
# the final bootstrap executable are the real first-install implementation.
|
||||
installer = load_installer()
|
||||
stem = f"dever-{manifest['platform']}"
|
||||
base = installer.RELEASES
|
||||
routes = {
|
||||
f"{base}/latest/{stem}.manifest.json": assets / f"{stem}.manifest.json",
|
||||
f"{base}/latest/{stem}.manifest.sig": assets / f"{stem}.manifest.sig",
|
||||
f"{base}/{manifest['version']}/{stem}.tar.zst": assets / f"{stem}.tar.zst",
|
||||
base = f"https://git.dever.cc/shemic/dever/releases/download/v{manifest['version']}"
|
||||
routes = {f"{base}/{path.name}": path for path in assets.iterdir() if path.is_file()}
|
||||
record = {
|
||||
"tag_name": f"v{manifest['version']}", "draft": False, "prerelease": False,
|
||||
"assets": [{"name": path.name, "size": path.stat().st_size,
|
||||
"browser_download_url": address} for address, path in routes.items()],
|
||||
}
|
||||
for artifact in manifest["artifacts"]:
|
||||
if artifact["path"] == "bootstrap/dever" or artifact["path"].startswith("bootstrap/lib/"):
|
||||
name = f"{stem}.blob-{artifact['sha256']}"
|
||||
routes[f"{base}/{manifest['version']}/{name}"] = assets / name
|
||||
installer.download = lambda address: routes[address].open("rb")
|
||||
|
||||
def download(address):
|
||||
if address == f"{installer.RELEASES}/latest":
|
||||
return io.BytesIO(json.dumps(record).encode())
|
||||
return routes[address].open("rb")
|
||||
|
||||
installer.download = download
|
||||
installer.install(argparse.Namespace(
|
||||
version="latest", release=None, system_root=image, no_start=True,
|
||||
trusted_key=WORKSPACE / "skills/assets/release.pub",
|
||||
|
||||
Reference in new issue
Block a user