Marketplace: refuse an update to a version that is not installable (PR review #1)
Install and update now share ops::installable_at_head: the reviewed head must still be the head and the item's catalog entry there must be valid. The old update check (item_files) never parsed hook.json, so an upstream hook with an unknown event could be pinned and then held by every sync. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,9 +8,7 @@ use tauri::{AppHandle, Emitter, State};
|
|||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
use crate::docker::container::is_container_running;
|
use crate::docker::container::is_container_running;
|
||||||
use crate::marketplace::{
|
use crate::marketplace::{self as mk, auth, diff, gh_login, git, MarketplaceManager};
|
||||||
self as mk, auth, catalog, diff, gh_login, git, tree::GitTree, MarketplaceManager,
|
|
||||||
};
|
|
||||||
use crate::models::marketplace::{
|
use crate::models::marketplace::{
|
||||||
is_valid_commit, is_valid_item_key, AccountMethod, FileDiff, InstallScope, ItemUpdate,
|
is_valid_commit, is_valid_item_key, AccountMethod, FileDiff, InstallScope, ItemUpdate,
|
||||||
Marketplace, MarketplaceAccount, MarketplaceInstall, MarketplaceItemRef, MarketplaceSnapshot,
|
Marketplace, MarketplaceAccount, MarketplaceInstall, MarketplaceItemRef, MarketplaceSnapshot,
|
||||||
@@ -24,7 +22,7 @@ use crate::AppState;
|
|||||||
/// testable without a Tauri runtime.
|
/// testable without a Tauri runtime.
|
||||||
pub(crate) mod ops {
|
pub(crate) mod ops {
|
||||||
use crate::marketplace::{auth, git};
|
use crate::marketplace::{auth, git};
|
||||||
use crate::models::marketplace::{MarketplaceInstall, MarketplaceItemRef};
|
use crate::models::marketplace::{MarketplaceInstall, MarketplaceItemRef, MarketplaceSnapshot};
|
||||||
|
|
||||||
/// Insert, or replace the install of the same item (a re-install re-pins).
|
/// Insert, or replace the install of the same item (a re-install re-pins).
|
||||||
pub fn upsert_install(list: &mut Vec<MarketplaceInstall>, inst: MarketplaceInstall) {
|
pub fn upsert_install(list: &mut Vec<MarketplaceInstall>, inst: MarketplaceInstall) {
|
||||||
@@ -82,6 +80,39 @@ pub(crate) mod ops {
|
|||||||
Ok(head.to_string())
|
Ok(head.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The one rule install and update share (PR review #1): pin
|
||||||
|
/// `expected` only if it is still the head (see [`reviewed_head`]) and
|
||||||
|
/// the item, as the catalog reads it at that head, is valid. `snap`'s
|
||||||
|
/// items are always parsed at `snap.head_commit`, so for a hook this
|
||||||
|
/// means its `hook.json` parses and names only known events — exactly
|
||||||
|
/// what the payload later requires. `action` is "installed"/"updated".
|
||||||
|
pub fn installable_at_head(
|
||||||
|
snap: &MarketplaceSnapshot,
|
||||||
|
item: &MarketplaceItemRef,
|
||||||
|
expected: &str,
|
||||||
|
marketplace_name: &str,
|
||||||
|
action: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let head = reviewed_head(snap.head_commit.as_deref(), expected, marketplace_name)?;
|
||||||
|
let entry = snap
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.find(|i| i.kind == item.kind && i.key == item.key)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"\"{}\" is no longer in \"{}\" — refresh the marketplace.",
|
||||||
|
item.key, marketplace_name
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if let Some(reason) = &entry.invalid {
|
||||||
|
return Err(format!(
|
||||||
|
"\"{}\" cannot be {} at this version: {}",
|
||||||
|
entry.name, action, reason
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(head)
|
||||||
|
}
|
||||||
|
|
||||||
/// An unvalidated value as it may appear in an error: quoted and escaped
|
/// An unvalidated value as it may appear in an error: quoted and escaped
|
||||||
/// (`{:?}`) and capped at 60 characters, since it can come from an
|
/// (`{:?}`) and capped at 60 characters, since it can come from an
|
||||||
/// import file rather than from what the person just typed.
|
/// import file rather than from what the person just typed.
|
||||||
@@ -263,6 +294,75 @@ pub(crate) mod ops {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PR review #1: install and update share one rule — the item as the
|
||||||
|
/// catalog reads it at the reviewed head must be valid. `item_files`
|
||||||
|
/// alone (the old update check) accepts a hook whose `hook.json` names
|
||||||
|
/// an unknown event, which every sync would then hold back.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_item_invalid_at_the_reviewed_head_is_neither_installed_nor_updated() {
|
||||||
|
use crate::marketplace::test_support::GitFixture;
|
||||||
|
use crate::marketplace::{catalog, tree::GitTree, MarketplaceManager};
|
||||||
|
use crate::models::marketplace::Marketplace;
|
||||||
|
use crate::models::AppSettings;
|
||||||
|
|
||||||
|
let Some(fx) = GitFixture::new() else { return };
|
||||||
|
fx.with_all_kinds();
|
||||||
|
fx.write(
|
||||||
|
"hooks/notify-on-stop/hook.json",
|
||||||
|
r#"{"hooks":{"PreFoo":[{"hooks":[{"type":"command","command":"x"}]}]}}"#,
|
||||||
|
);
|
||||||
|
let head = fx.commit("bad hook event");
|
||||||
|
let data = tempfile::tempdir().unwrap();
|
||||||
|
let mgr = MarketplaceManager::new(data.path().to_path_buf());
|
||||||
|
let mut settings = AppSettings::default();
|
||||||
|
settings.marketplaces.push(Marketplace {
|
||||||
|
id: "m1".into(),
|
||||||
|
name: "Team".into(),
|
||||||
|
url: fx.url(),
|
||||||
|
branch: None,
|
||||||
|
account_id: None,
|
||||||
|
});
|
||||||
|
let snap = crate::marketplace::refresh_marketplace(&mgr, &settings, "m1").await;
|
||||||
|
|
||||||
|
let repo = crate::marketplace::git::cache_path(data.path(), "m1");
|
||||||
|
let tree = GitTree::open(&repo, &head).unwrap();
|
||||||
|
assert!(
|
||||||
|
catalog::item_files(&tree, ItemKind::Hook, "notify-on-stop").is_ok(),
|
||||||
|
"the old update check let this through"
|
||||||
|
);
|
||||||
|
|
||||||
|
let hook = MarketplaceItemRef {
|
||||||
|
marketplace_id: "m1".into(),
|
||||||
|
kind: ItemKind::Hook,
|
||||||
|
key: "notify-on-stop".into(),
|
||||||
|
};
|
||||||
|
for action in ["installed", "updated"] {
|
||||||
|
let e = installable_at_head(&snap, &hook, &head, "Team", action).unwrap_err();
|
||||||
|
assert!(e.contains(&format!("cannot be {action}")), "{e}");
|
||||||
|
assert!(e.contains("PreFoo"), "{e}");
|
||||||
|
}
|
||||||
|
let agent = MarketplaceItemRef {
|
||||||
|
kind: ItemKind::Agent,
|
||||||
|
key: "code-reviewer".into(),
|
||||||
|
..hook.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
installable_at_head(&snap, &agent, &head, "Team", "updated").unwrap(),
|
||||||
|
head
|
||||||
|
);
|
||||||
|
let gone = MarketplaceItemRef {
|
||||||
|
key: "no-such-agent".into(),
|
||||||
|
..agent
|
||||||
|
};
|
||||||
|
let e = installable_at_head(&snap, &gone, &head, "Team", "updated").unwrap_err();
|
||||||
|
assert!(e.contains("no longer in"), "{e}");
|
||||||
|
assert!(
|
||||||
|
installable_at_head(&snap, &hook, &"b".repeat(40), "Team", "installed")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("changed since you reviewed")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Pre-flight F13: the add form and the fetch agree on what a branch
|
/// Pre-flight F13: the add form and the fetch agree on what a branch
|
||||||
/// is, so a name the fetch would refuse is refused up front.
|
/// is, so a name the fetch would refuse is refused up front.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -693,23 +793,7 @@ pub async fn install_marketplace_item(
|
|||||||
let settings = state.settings_store.get();
|
let settings = state.settings_store.get();
|
||||||
let m = find_marketplace(&settings, &item.marketplace_id)?;
|
let m = find_marketplace(&settings, &item.marketplace_id)?;
|
||||||
let snap = snapshot_blocking(&state, &m).await?;
|
let snap = snapshot_blocking(&state, &m).await?;
|
||||||
let head = ops::reviewed_head(snap.head_commit.as_deref(), &expected_commit, &m.name)?;
|
let head = ops::installable_at_head(&snap, &item, &expected_commit, &m.name, "installed")?;
|
||||||
let entry = snap
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.find(|i| i.kind == item.kind && i.key == item.key)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"\"{}\" is no longer in \"{}\" — refresh the marketplace.",
|
|
||||||
item.key, m.name
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if let Some(reason) = &entry.invalid {
|
|
||||||
return Err(format!(
|
|
||||||
"\"{}\" cannot be installed: {}",
|
|
||||||
entry.name, reason
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let inst = MarketplaceInstall {
|
let inst = MarketplaceInstall {
|
||||||
marketplace_id: item.marketplace_id.clone(),
|
marketplace_id: item.marketplace_id.clone(),
|
||||||
kind: item.kind,
|
kind: item.kind,
|
||||||
@@ -829,17 +913,7 @@ pub async fn update_marketplace_item(
|
|||||||
let settings = state.settings_store.get();
|
let settings = state.settings_store.get();
|
||||||
let m = find_marketplace(&settings, &item.marketplace_id)?;
|
let m = find_marketplace(&settings, &item.marketplace_id)?;
|
||||||
let snap = snapshot_blocking(&state, &m).await?;
|
let snap = snapshot_blocking(&state, &m).await?;
|
||||||
let head = ops::reviewed_head(snap.head_commit.as_deref(), &expected_commit, &m.name)?;
|
let head = ops::installable_at_head(&snap, &item, &expected_commit, &m.name, "updated")?;
|
||||||
|
|
||||||
let repo = git::cache_path(state.marketplace.data_root(), &m.id);
|
|
||||||
let (kind, key, at) = (item.kind, item.key.clone(), head.clone());
|
|
||||||
tokio::task::spawn_blocking(move || -> Result<(), String> {
|
|
||||||
let tree = GitTree::open(&repo, &at)?;
|
|
||||||
catalog::item_files(&tree, kind, &key).map(|_| ())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Checking the new version failed: {e}"))?
|
|
||||||
.map_err(|e| format!("\"{}\" cannot be updated: {e}", item.key))?;
|
|
||||||
|
|
||||||
match scope {
|
match scope {
|
||||||
InstallScope::Global => {
|
InstallScope::Global => {
|
||||||
|
|||||||
Reference in New Issue
Block a user