act_runtime/resolve.rs
1//! Component reference resolution, backed by the shared `act-store`.
2//!
3//! `ComponentRef` is re-exported from `act-store` (the parsing source of truth).
4//! Local refs run in place; remote refs (OCI/HTTP) resolve read-through the
5//! store (pulled on first use, then served from disk).
6
7use std::path::PathBuf;
8
9use anyhow::{Context, Result};
10use path_clean::PathClean;
11
12pub use act_store::Ref as ComponentRef;
13
14/// Open the shared component store at its platform default location.
15pub fn open_store() -> Result<act_store::Store> {
16 let dir = act_store::store_dir().context("locating component store")?;
17 act_store::Store::open(&dir).context("opening component store")
18}
19
20/// Resolve a component reference to a local `.wasm` path.
21///
22/// Local files are used in place (never copied into the store). Remote refs
23/// (OCI/HTTP) are served read-through from the store; `fresh` forces a re-pull.
24pub async fn resolve(component_ref: &ComponentRef, fresh: bool) -> Result<PathBuf> {
25 if let ComponentRef::Local(path) = component_ref {
26 anyhow::ensure!(
27 tokio::fs::try_exists(path).await.unwrap_or(false),
28 "component not found: {}",
29 path.display()
30 );
31 return Ok(path.clone());
32 }
33 let store = open_store()?;
34 let reference = component_ref.to_string();
35 if fresh {
36 act_store::pull(&store, &reference)
37 .await
38 .with_context(|| format!("pulling {reference}"))?;
39 }
40 act_store::ensure(&store, &reference)
41 .await
42 .with_context(|| format!("resolving {reference}"))
43}
44
45/// The stable key a component's credential profile is namespaced under.
46///
47/// This is *not* `component_ref.to_string()`. For `Http`/`Oci`/`Name` refs
48/// `to_string()` is returned unchanged: it is already canonical *as a
49/// string* (a parsed URL, a registry ref matched by the OCI regex, a bare
50/// name) — but see the caveat below, because canonical as a string is not
51/// the same as canonical as an identity. For `Local` it is not even that:
52/// `to_string()` is `path.display()` verbatim, so `./notion.wasm`,
53/// `notion.wasm` and its absolute form would each open a *different*
54/// profile for the same file — `act secret set ./notion.wasm` followed by
55/// `act run notion.wasm` would silently miss.
56///
57/// Relative local paths are joined onto the current directory and
58/// lexically cleaned (`path_clean`, no filesystem access — the component
59/// need not exist yet, e.g. before a first `act pull`), so every spelling
60/// of the same path agrees. Both `act secret set/list/rm` and the runtime's
61/// `credential_host` (main.rs) key their profile lookups through this
62/// function, so they cannot drift apart.
63///
64/// # A remote ref's tag is part of the profile identity
65///
66/// For remote refs the whole ref string is the key, tag and digest included,
67/// and this function does nothing to narrow it. So
68/// `ghcr.io/actpkg/notion:0.1.0`, `…/notion:0.2.0`, `…/notion`,
69/// `…/notion:latest` and `…/notion@sha256:…` are **five distinct profiles**
70/// for what an operator thinks of as one component, and provisioning against
71/// one while running another gets a bare `not-found`:
72///
73/// ```text
74/// act secret set ghcr.io/actpkg/notion:0.1.0 --key mcp.notion.com …
75/// act run ghcr.io/actpkg/notion:0.2.0 # other profile → not-found
76/// ```
77///
78/// This fails closed — a version bump never hands a new artifact the old
79/// artifact's credential, which is the safe direction, and it is why phase 1
80/// ships as is rather than guessing at an equivalence between refs. What it
81/// costs is that every upgrade is a silent re-provisioning event whose only
82/// symptom is `not-found`.
83///
84/// Phase 2 owes one of two remedies, and the choice is deliberately left
85/// open here: canonicalise remote refs to the repository without the tag, or
86/// keep the key and make the first `not-found` name the profile it looked in
87/// (design §5.2's "first-failure message carrying a copy-pasteable
88/// command"). Until then this is a documented sharp edge, not a bug.
89pub fn profile_key(component_ref: &ComponentRef) -> String {
90 match component_ref {
91 ComponentRef::Local(path) => {
92 let abs = if path.is_absolute() {
93 path.clone()
94 } else {
95 std::env::current_dir().map_or_else(|_| path.clone(), |cwd| cwd.join(path))
96 };
97 abs.clean().display().to_string()
98 }
99 other => other.to_string(),
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 #[test]
108 fn local_refs_are_lexically_cleaned_without_touching_the_filesystem() {
109 // Absolute, so this is deterministic regardless of the test
110 // process's current directory; `..`/`.` are cleaned away purely
111 // lexically, on a path that need not exist on disk.
112 let key = profile_key(&ComponentRef::Local(PathBuf::from(
113 "/abs/a/./sub/../c.wasm",
114 )));
115 assert_eq!(key, "/abs/a/c.wasm");
116 }
117
118 #[test]
119 fn non_local_refs_pass_through_unchanged() {
120 let oci: ComponentRef = "ghcr.io/actpkg/notion:0.1.0".parse().unwrap();
121 assert_eq!(profile_key(&oci), oci.to_string());
122
123 let name: ComponentRef = "sqlite".parse().unwrap();
124 assert_eq!(profile_key(&name), "sqlite");
125 }
126}