Skip to content

Commit

Permalink
Upgrade pgxn_meta and add download validation
Browse files Browse the repository at this point in the history
Take advantage of the errors defined in pgxn_meta v0.5.0 rather than
stringifying them. Add tests for errors returned by the `meta` and
`download` APIs.

Change the signature for `download_to` to take a pgxn_meta Release
struct instead of a distribution name and version. Then use the Release
both to determine the name and version to download, and then to validate
the download against the digests in the Release. Update the tests for
the new signature, and to ensure digest validation.

Also: Update dependencies.
  • Loading branch information
theory committed Nov 15, 2024
1 parent d6b7f78 commit 01afc65
Show file tree
Hide file tree
Showing 6 changed files with 174 additions and 48 deletions.
4 changes: 3 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
/Cargo.lock -diff
# Disable line-ending conversion of JSON files, so that the hash digest
# validation tests pass on Windows.
*.json -text
50 changes: 38 additions & 12 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 14 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
[package]
name = "pgxn_build"
version = "0.1.0"
description = "Build PGXN distributions"
repository = "https://github.com/pgxn/build"
documentation = "https://docs.rs/pgxn_build/"
authors = ["David E. Wheeler <[email protected]>"]
readme = "README.md"
keywords = ["pgxn", "postgres", "postgresql", "extension", "validation"]
license = "PostgreSQL"
categories = ["web-programming", "database"]
edition = "2021"
exclude = [ ".github", ".vscode", ".gitignore", ".ci", ".pre-*.yaml"]

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
chrono = "0.4"
iri-string = "0.7"
pgxn_meta = "0.4"
pgxn_meta = "0.5.1"
semver = "1.0"
serde = "1.0"
serde_json = "1.0"
thiserror = "1.0"
thiserror = "2.0"
ureq = { version = "*", features = ["json"] }
url = "2.5"
zip = "2.2"

[dev-dependencies]
httpmock = "0.7"
sha2 = "0.10"
tempfile = "3.12"
tempfile = "3.14"
hex = "0.4"
22 changes: 12 additions & 10 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,13 @@ impl Api {
let mut val = fetch_json(&self.agent, &url)?;
if val.get("meta-spec").is_none() {
// PGXN v1 stripped meta-spec out of this API :-/.
let val_type = type_of(&val);
val.as_object_mut()
.unwrap()
.ok_or_else(|| BuildError::Type(url.to_string(), "object", val_type))?
.insert("meta-spec".to_string(), json!({"version": "1.0.0"}));
}
pgxn_meta::release::Release::try_from(val)
.map_err(|e| BuildError::InvalidMeta(e.to_string()))
let rel = pgxn_meta::release::Release::try_from(val)?;
Ok(rel)
}

/// Unpack download `file` in directory `into` and return the path to the
Expand Down Expand Up @@ -114,19 +115,20 @@ impl Api {
Ok(url)
}

/// Download version `version` of `dist` to `dir`. Returns the full path
/// to the file.
/// Download the archive for release `meta` to `dir` and validate it
/// against the digests in `meta`. Returns the full path to the file.
pub fn download_to<P: AsRef<Path>>(
&self,
dir: P,
dist: &str,
version: &str,
meta: &pgxn_meta::release::Release,
) -> Result<PathBuf, BuildError> {
let mut ctx = SimpleContext::new();
ctx.insert("dist", dist);
ctx.insert("version", version);
ctx.insert("dist", meta.name());
ctx.insert("version", meta.version().to_string());
let url = self.url_for("download", ctx)?;
self.download_url_to(dir, url)
let file = self.download_url_to(dir, url)?;
meta.release().digests().validate(&file)?;
Ok(file)
}

/// Download `url` to `dir`. The file name must be the last segment of the
Expand Down
125 changes: 105 additions & 20 deletions src/api/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,19 @@ fn constructor_proxy() -> Result<(), BuildError> {
fn download_file() -> Result<(), BuildError> {
let dir = corpus_dir();
let url = format!("file://{}", dir.display());
let tmp_dir = tempdir()?;
let exp_path = tmp_dir.as_ref().join("pair-0.1.7.zip");

// Load the distribution release meta.
let api = Api::new(&url, None)?;
let v = Version::new(0, 1, 7);
let meta = api.meta("pair", &v)?;

// Download the file.
let tmp_dir = tempdir()?;
let exp_path = tmp_dir.as_ref().join("pair-0.1.7.zip");
assert!(!exp_path.exists());
let api = Api::new(&url, None)?;
assert_eq!(
tmp_dir.path().join("pair-0.1.7.zip"),
api.download_to(tmp_dir.as_ref(), "pair", "0.1.7")?
api.download_to(tmp_dir.as_ref(), &meta)?
);
assert!(exp_path.exists());

Expand All @@ -87,21 +91,10 @@ fn download_file() -> Result<(), BuildError> {
#[test]
fn download_http() -> Result<(), BuildError> {
let dir = corpus_dir();
let src_path = dir
.join("dist")
.join("pair")
.join("0.1.7")
.join("pair-0.1.7.zip");
let src_path = dir.join("dist").join("pair").join("0.1.7");

// Start a lightweight mock server.
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/dist/pair/0.1.7/pair-0.1.7.zip");
then.status(200)
.header("content-type", "application/zip")
.body_from_file(src_path.display().to_string());
});

let idx_url = format!("file://{}/index.json", dir.display());
let idx_url = Url::parse(&idx_url)?;
let agent = ureq::agent();
Expand All @@ -114,16 +107,43 @@ fn download_http() -> Result<(), BuildError> {
templates,
};

// Load the distribution release meta.
let mock = server.mock(|when, then| {
when.method(GET).path("/dist/pair/0.1.7/META.json");
then.status(200)
.header("content-type", "application/json")
.body_from_file(src_path.join("META.json").display().to_string());
});
let v = Version::new(0, 1, 7);
let meta = api.meta("pair", &v)?;
mock.assert();

// Download the file.
let mut mock = server.mock(|when, then| {
when.method(GET).path("/dist/pair/0.1.7/pair-0.1.7.zip");
then.status(200)
.header("content-type", "application/zip")
.body_from_file(src_path.join("pair-0.1.7.zip").display().to_string());
});
let tmp_dir = tempdir()?;
let exp_path = tmp_dir.as_ref().join("pair-0.1.7.zip");
assert!(!exp_path.exists());
assert_eq!(
exp_path,
api.download_to(tmp_dir.as_ref(), "pair", "0.1.7")?,
);
assert_eq!(exp_path, api.download_to(tmp_dir.as_ref(), &meta)?);
assert!(exp_path.exists());
mock.assert();
mock.delete();

// Try a validation failure.
let mock = server.mock(|when, then| {
when.method(GET).path("/dist/pair/0.1.7/pair-0.1.7.zip");
then.status(200)
.header("content-type", "application/zip")
.body_from_file(src_path.join("META.json").display().to_string());
});
let res = api.download_to(tmp_dir.as_ref(), &meta);
mock.assert();
assert!(res.is_err());
assert_eq!("SHA-1 digest cafa55f06cdc9861b23de72687024b02322ad21c does not match 5b9e3ba948b18703227e4dea17696c0f1d971759", res.unwrap_err().to_string());

Ok(())
}
Expand Down Expand Up @@ -690,6 +710,62 @@ fn meta() -> Result<(), BuildError> {
Ok(())
}

#[test]
fn meta_err() -> Result<(), BuildError> {
// Start a lightweight mock server.
let server = MockServer::start();
let base_url = Url::parse(&server.base_url())?;

// Load the URL templates.
let idx_url = format!("file://{}/index.json", corpus_dir().display());
let idx_url = Url::parse(&idx_url)?;
let agent = ureq::agent();
let templates = fetch_templates(&agent, &idx_url)?;

// Set up an Api.
let api = Api {
url: base_url.clone(),
agent,
templates,
};

// Test an invalid META file json value.
let mock = server.mock(|when, then| {
when.method(GET).path("/dist/bad_meta/0.0.1/META.json");
then.status(200)
.header("content-type", "application/json")
.body("[]");
});
let v = Version::parse("0.0.1").unwrap();
let meta = api.meta("bad_meta", &v);
mock.assert();
assert!(meta.is_err());
assert_eq!(
format!(
"invalid type: {} expected to be object but got array",
base_url.join("dist/bad_meta/0.0.1/META.json")?,
),
meta.unwrap_err().to_string()
);

// Test an invalid META file json value.
let mock = server.mock(|when, then| {
when.method(GET).path("/dist/invalid_meta/0.0.1/META.json");
then.status(200)
.header("content-type", "application/json")
.body("{}");
});
let meta = api.meta("invalid_meta", &v);
mock.assert();
assert!(meta.is_err());
assert!(meta
.unwrap_err()
.to_string()
.contains("missing properties 'name', 'version', 'abstract'"));

Ok(())
}

#[test]
fn unpack() -> Result<(), BuildError> {
let dir = corpus_dir();
Expand Down Expand Up @@ -724,6 +800,15 @@ fn unpack() -> Result<(), BuildError> {
assert!(file.exists(), "{}", file.display());
}

// Test an invalid zip file.
let idx = corpus_dir().join("index.json");
let res = api.unpack(tmp_dir.as_ref(), &idx);
assert!(res.is_err());
assert_eq!(
"invalid Zip archive: No valid central directory found",
res.unwrap_err().to_string()
);

Ok(())
}

Expand Down
Loading

0 comments on commit 01afc65

Please sign in to comment.