Skip to content

Commit 825f6f5

Browse files
fix: Consistent caching and checksumming (#361)
1 parent 2ed3fbe commit 825f6f5

3 files changed

Lines changed: 134 additions & 129 deletions

File tree

crates/icp/src/canister/recipe/handlebars.rs

Lines changed: 75 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ use crate::{
1616
canister::{BuildSteps, SyncSteps},
1717
recipe::{Recipe, RecipeType},
1818
},
19-
package::{PackageCache, cache_recipe, read_cached_recipe},
19+
package::{
20+
PackageCache, cache_registry_recipe, cache_uri_recipe, read_cached_registry_recipe,
21+
read_cached_uri_recipe,
22+
},
2023
prelude::*,
2124
};
2225

@@ -78,12 +81,6 @@ pub enum HandlebarsError {
7881

7982
#[snafu(display("failed to acquire lock on package cache"))]
8083
LockCache { source: crate::fs::lock::LockError },
81-
82-
#[snafu(display("failed to resolve git tag '{tag}' for recipe"))]
83-
ResolveGitTag { source: reqwest::Error, tag: String },
84-
85-
#[snafu(display("failed to parse git tag response for '{tag}'"))]
86-
ParseGitTag { tag: String },
8784
}
8885

8986
impl Handlebars {
@@ -92,7 +89,7 @@ impl Handlebars {
9289
recipe: &Recipe,
9390
) -> Result<(BuildSteps, SyncSteps), HandlebarsError> {
9491
// Determine the template source
95-
let tmpl = match &recipe.recipe_type {
92+
let tmpl_source = match &recipe.recipe_type {
9693
RecipeType::File(path) => TemplateSource::LocalPath(Path::new(&path).into()),
9794
RecipeType::Url(url) => TemplateSource::RemoteUrl(url.to_owned()),
9895
RecipeType::Registry {
@@ -103,18 +100,30 @@ impl Handlebars {
103100
};
104101

105102
// Retrieve the template, using cache for remote/registry sources
106-
let tmpl = match tmpl {
103+
let (tmpl, should_cache) = match &tmpl_source {
107104
TemplateSource::LocalPath(path) => {
108-
let bytes = read(&path).context(ReadFileSnafu)?;
109-
if let Some(expected) = &recipe.sha256 {
110-
verify_checksum(&bytes, expected)?;
111-
}
112-
parse_bytes_to_string(bytes)?
105+
let bytes = read(path).context(ReadFileSnafu)?;
106+
(parse_bytes_to_string(bytes)?, false)
113107
}
114108

115109
TemplateSource::RemoteUrl(u) => {
116-
self.fetch_remote_template(&u, recipe.sha256.as_deref())
117-
.await?
110+
// Check cache
111+
let maybe_cached = self
112+
.pkg_cache
113+
.with_read(async |r| {
114+
read_cached_uri_recipe(r, u, recipe.sha256.as_deref())
115+
.context(ReadCacheSnafu)
116+
})
117+
.await
118+
.context(LockCacheSnafu)?;
119+
if let Some(cached) = maybe_cached? {
120+
debug!("Using cached recipe template for {u}");
121+
(parse_bytes_to_string(cached)?, false)
122+
} else {
123+
// Download the template
124+
let tmpl = self.fetch_remote_bytes(u).await?;
125+
(parse_bytes_to_string(tmpl)?, true)
126+
}
118127
}
119128

120129
// TMP(or.ricon): Temporarily hardcode a dfinity registry
@@ -130,43 +139,31 @@ impl Handlebars {
130139
let maybe_cached = self
131140
.pkg_cache
132141
.with_read(async |r| {
133-
read_cached_recipe(r, &package, &version).context(ReadCacheSnafu)
142+
read_cached_registry_recipe(r, &package, version).context(ReadCacheSnafu)
134143
})
135144
.await
136145
.context(LockCacheSnafu)?;
137146
if let Some(cached) = maybe_cached? {
138147
debug!("Using cached recipe template for {package}@{version}");
139-
parse_bytes_to_string(cached)?
148+
(parse_bytes_to_string(cached)?, false)
140149
} else {
141150
// Download the template
142151
let url = format!(
143152
"https://github.com/dfinity/icp-cli-recipes/releases/download/{release_tag}/recipe.hbs"
144153
);
145154
let bytes = self.fetch_remote_bytes(&url).await?;
146155

147-
if let Some(expected) = &recipe.sha256 {
148-
verify_checksum(&bytes, expected)?;
149-
}
150-
151-
// Resolve the git tag to a commit SHA for caching
152-
let git_sha = self
153-
.resolve_git_tag_sha("dfinity", "icp-cli-recipes", &release_tag)
154-
.await?;
155-
156-
// Cache the template keyed by the git SHA
157-
self.pkg_cache
158-
.with_write(async |w| {
159-
cache_recipe(w, &package, &version, &git_sha, &bytes)
160-
.context(CacheRecipeSnafu)
161-
})
162-
.await
163-
.context(LockCacheSnafu)??;
164-
165-
parse_bytes_to_string(bytes)?
156+
(parse_bytes_to_string(bytes)?, true)
166157
}
167158
}
168159
};
169160

161+
let hash = if let Some(sha256) = &recipe.sha256 {
162+
verify_checksum(tmpl.as_bytes(), sha256)?
163+
} else {
164+
Sha256::digest(tmpl.as_bytes()).into()
165+
};
166+
170167
// Load the template via handlebars
171168
let mut reg = handlebars::Handlebars::new();
172169

@@ -204,8 +201,8 @@ impl Handlebars {
204201
}
205202

206203
let insts = serde_yaml::from_str::<BuildSyncHelper>(&out);
207-
match insts {
208-
Ok(helper) => Ok((helper.build, helper.sync)),
204+
let (build, sync) = match insts {
205+
Ok(helper) => (helper.build, helper.sync),
209206
Err(e) => panic!(
210207
"{}",
211208
formatdoc! {r#"
@@ -217,7 +214,41 @@ impl Handlebars {
217214
------
218215
"#, recipe.recipe_type}
219216
),
217+
};
218+
219+
// The template is verified good - now cache it if it was remote
220+
if should_cache {
221+
match tmpl_source {
222+
TemplateSource::LocalPath(_) => unreachable!("local files are never cached"),
223+
TemplateSource::RemoteUrl(u) => {
224+
self.pkg_cache
225+
.with_write(async |w| {
226+
cache_uri_recipe(w, &u, &hex::encode(hash), tmpl.as_bytes())
227+
.context(CacheRecipeSnafu)?;
228+
Ok(())
229+
})
230+
.await
231+
.context(LockCacheSnafu)??;
232+
}
233+
TemplateSource::Registry(registry, recipe_name, version) => {
234+
let package = format!("@{registry}/{recipe_name}");
235+
self.pkg_cache
236+
.with_write(async |w| {
237+
cache_registry_recipe(
238+
w,
239+
&package,
240+
&version,
241+
&hex::encode(hash),
242+
tmpl.as_bytes(),
243+
)
244+
.context(CacheRecipeSnafu)
245+
})
246+
.await
247+
.context(LockCacheSnafu)??;
248+
}
249+
}
220250
}
251+
Ok((build, sync))
221252
}
222253

223254
/// Fetch raw bytes from a remote URL.
@@ -241,80 +272,6 @@ impl Handlebars {
241272

242273
Ok(resp.bytes().await.context(HttpRequestSnafu)?.to_vec())
243274
}
244-
245-
/// Fetch a remote template, verifying checksum if provided.
246-
async fn fetch_remote_template(
247-
&self,
248-
url: &str,
249-
sha256: Option<&str>,
250-
) -> Result<String, HandlebarsError> {
251-
let bytes = self.fetch_remote_bytes(url).await?;
252-
if let Some(expected) = sha256 {
253-
verify_checksum(&bytes, expected)?;
254-
}
255-
parse_bytes_to_string(bytes)
256-
}
257-
258-
/// Resolve a GitHub release tag to its underlying git commit SHA.
259-
async fn resolve_git_tag_sha(
260-
&self,
261-
owner: &str,
262-
repo: &str,
263-
tag: &str,
264-
) -> Result<String, HandlebarsError> {
265-
let ref_response = self
266-
.github_api_get(
267-
&format!("https://api.github.com/repos/{owner}/{repo}/git/ref/tags/{tag}"),
268-
tag,
269-
)
270-
.await?;
271-
272-
let obj_type = ref_response["object"]["type"]
273-
.as_str()
274-
.ok_or_else(|| ParseGitTagSnafu { tag }.build())?;
275-
let obj_sha = ref_response["object"]["sha"]
276-
.as_str()
277-
.ok_or_else(|| ParseGitTagSnafu { tag }.build())?;
278-
279-
match obj_type {
280-
// Lightweight tag — points directly at the commit
281-
"commit" => Ok(obj_sha.to_owned()),
282-
// Annotated tag — dereference through the tag object to get the commit
283-
"tag" => {
284-
let tag_response = self
285-
.github_api_get(
286-
&format!("https://api.github.com/repos/{owner}/{repo}/git/tags/{obj_sha}"),
287-
tag,
288-
)
289-
.await?;
290-
tag_response["object"]["sha"]
291-
.as_str()
292-
.map(str::to_owned)
293-
.ok_or_else(|| ParseGitTagSnafu { tag }.build())
294-
}
295-
_ => ParseGitTagSnafu { tag }.fail(),
296-
}
297-
}
298-
299-
/// Make an authenticated GET request to the GitHub API.
300-
async fn github_api_get(
301-
&self,
302-
url: &str,
303-
tag: &str,
304-
) -> Result<serde_json::Value, HandlebarsError> {
305-
let mut req = self.http_client.get(url).header("User-Agent", "icp-cli");
306-
if let Ok(token) = std::env::var("ICP_CLI_GITHUB_TOKEN") {
307-
req = req.bearer_auth(token);
308-
}
309-
req.send()
310-
.await
311-
.context(ResolveGitTagSnafu { tag })?
312-
.error_for_status()
313-
.context(ResolveGitTagSnafu { tag })?
314-
.json()
315-
.await
316-
.context(ResolveGitTagSnafu { tag })
317-
}
318275
}
319276

320277
#[async_trait]
@@ -353,22 +310,21 @@ impl HelperDef for ReplaceHelper {
353310
}
354311

355312
/// Helper function to verify sha256 checksum of recipe template bytes
356-
fn verify_checksum(bytes: &[u8], expected: &str) -> Result<(), HandlebarsError> {
357-
let actual = hex::encode({
313+
fn verify_checksum(bytes: &[u8], expected: &str) -> Result<[u8; 32], HandlebarsError> {
314+
let actual_hash = {
358315
let mut h = Sha256::new();
359316
h.update(bytes);
360317
h.finalize()
361-
});
362-
318+
};
319+
let actual = hex::encode(actual_hash);
363320
if actual != expected {
364321
return ChecksumMismatchSnafu {
365322
expected: expected.to_string(),
366323
actual,
367324
}
368325
.fail();
369326
}
370-
371-
Ok(())
327+
Ok(actual_hash.into())
372328
}
373329

374330
/// Helper function to parse bytes into a UTF-8 string

crates/icp/src/manifest/recipe.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use serde::{Deserialize, de::Error as _};
66
/// Represents the accepted values for a recipe type in
77
/// the canister manifest
88
#[derive(Clone, Debug, PartialEq, JsonSchema)]
9-
#[serde(rename_all = "lowercase", from = "String")]
9+
#[schemars(from = "String")]
1010
pub enum RecipeType {
1111
/// path to a locally defined recipe
1212
File(String),

crates/icp/src/package.rs

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ pub fn cache_prebuilt(
108108
/// Read a cached recipe template by recipe and version (e.g., `@dfinity/rust`, `v1.0.2`).
109109
/// Resolves the version to a git SHA via the package manifest, then reads
110110
/// the cached template from `recipes/{sha}/recipe.hbs`.
111-
pub fn read_cached_recipe(
111+
pub fn read_cached_registry_recipe(
112112
cache: LRead<&PackageCachePaths>,
113113
recipe: &str,
114114
version: &str,
@@ -119,7 +119,35 @@ pub fn read_cached_recipe(
119119
else {
120120
return Ok(None);
121121
};
122-
let cache_path = cache.recipe_sha(&sha);
122+
read_cached_recipe(cache, &sha)
123+
}
124+
125+
pub fn read_cached_uri_recipe(
126+
cache: LRead<&PackageCachePaths>,
127+
uri: &str,
128+
sha2: Option<&str>,
129+
) -> Result<Option<Vec<u8>>, RecipeCacheError> {
130+
assert!(uri.starts_with("http://") || uri.starts_with("https://"));
131+
let Some(latest) =
132+
get_tag(cache, &format!("uri+{uri}"), "latest").context(LoadRecipeTagSnafu)?
133+
else {
134+
return Ok(None);
135+
};
136+
if let Some(sha2) = sha2
137+
&& sha2 != latest
138+
{
139+
// the content at this URL has clearly changed, but we may have a cached recipe for the old version
140+
read_cached_recipe(cache, sha2)
141+
} else {
142+
read_cached_recipe(cache, &latest)
143+
}
144+
}
145+
146+
pub fn read_cached_recipe(
147+
cache: LRead<&PackageCachePaths>,
148+
cache_key: &str,
149+
) -> Result<Option<Vec<u8>>, RecipeCacheError> {
150+
let cache_path = cache.recipe_sha(cache_key);
123151
let template_path = cache_path.template();
124152
if template_path.exists() {
125153
let template = crate::fs::read(&template_path).context(RecipeCacheIoSnafu)?;
@@ -131,19 +159,40 @@ pub fn read_cached_recipe(
131159
}
132160

133161
/// Cache a recipe template. `recipe` is the registry-qualified name (e.g., `@dfinity/rust`),
134-
/// `version` is the recipe version (e.g., `v1.0.2`), and `sha` is the git commit SHA that
135-
/// the version resolves to. Stores the version→SHA mapping in the package manifest and
136-
/// writes the template to `recipes/{sha}/recipe.hbs`.
137-
pub fn cache_recipe(
162+
/// `version` is the recipe version (e.g., `v1.0.2`), and `sha2` is the hash of the recipe
163+
/// (not the git commit sha). Stores the version→SHA mapping in the package manifest and
164+
/// writes the template to `recipes/{sha2}/recipe.hbs`.
165+
pub fn cache_registry_recipe(
138166
cache: LWrite<&PackageCachePaths>,
139167
recipe: &str,
140168
version: &str,
141-
sha: &str,
169+
sha2: &str,
142170
template: &[u8],
143171
) -> Result<(), RecipeCacheError> {
144172
assert!(recipe.starts_with('@'));
145-
set_tag(cache, &format!("recipe{recipe}"), sha, version).context(SaveRecipeTagSnafu)?;
146-
let cache_path = cache.recipe_sha(sha);
173+
set_tag(cache, &format!("recipe{recipe}"), sha2, version).context(SaveRecipeTagSnafu)?;
174+
cache_recipe(cache, sha2, template)
175+
}
176+
177+
/// Cache a recipe template from a URL. Stores the version→SHA mapping in the package manifest
178+
/// and writes the template to `recipes/{sha2}/recipe.hbs`.
179+
pub fn cache_uri_recipe(
180+
cache: LWrite<&PackageCachePaths>,
181+
uri: &str,
182+
sha2: &str,
183+
template: &[u8],
184+
) -> Result<(), RecipeCacheError> {
185+
assert!(uri.starts_with("http://") || uri.starts_with("https://"));
186+
set_tag(cache, &format!("uri+{uri}"), sha2, "latest").context(SaveRecipeTagSnafu)?;
187+
cache_recipe(cache, sha2, template)
188+
}
189+
190+
pub fn cache_recipe(
191+
cache: LWrite<&PackageCachePaths>,
192+
cache_key: &str,
193+
template: &[u8],
194+
) -> Result<(), RecipeCacheError> {
195+
let cache_path = cache.recipe_sha(cache_key);
147196
let template_path = cache_path.template();
148197
if !template_path.exists() {
149198
crate::fs::create_dir_all(cache_path.dir()).context(RecipeCacheIoSnafu)?;

0 commit comments

Comments
 (0)