Repository: bytecodealliance/wasm-tools
Pull request #1945 (2024-12-10 00:39:31)

[wasm-metadata] add support for homepage custom section
Adds the `homepage` custom section. Makes progress towards #1922. Thanks!


Patch:
diff --git a/Cargo.toml b/Cargo.toml
index 40fcf796..239bf9ad 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -86,6 +86,7 @@ rayon = "1.3"
 serde = { version = "1.0.166", default-features = false, features = ['alloc'] }
 serde_derive = "1.0.166"
 serde_json = { version = "1" }
+spdx = "0.10.1"
 wasmtime = { version = "25.0.0", default-features = false, features = ['cranelift', 'component-model', 'runtime', 'gc'] }
 url = "2.0.0"
 pretty_assertions = "1.3.0"
diff --git a/crates/wasm-metadata/Cargo.toml b/crates/wasm-metadata/Cargo.toml
index 70911348..5b91ec02 100644
--- a/crates/wasm-metadata/Cargo.toml
+++ b/crates/wasm-metadata/Cargo.toml
@@ -18,6 +18,6 @@ wasm-encoder = { workspace = true, features = ['component-model'] }
 indexmap = { workspace = true, features = ["serde"] }
 serde = { workspace = true }
 serde_derive = { workspace = true }
-serde_json = { version = "1" }
-spdx = "0.10.1"
+serde_json = { workspace = true }
+spdx = { workspace = true }
 url = { workspace = true }
diff --git a/crates/wasm-metadata/src/add_metadata.rs b/crates/wasm-metadata/src/add_metadata.rs
index c30e273d..089ee08d 100644
--- a/crates/wasm-metadata/src/add_metadata.rs
+++ b/crates/wasm-metadata/src/add_metadata.rs
@@ -1,4 +1,4 @@
-use crate::{rewrite_wasm, Author, Description, Licenses, Producers, Source};
+use crate::{rewrite_wasm, Author, Description, Homepage, Licenses, Producers, Source};
 
 use anyhow::Result;
 
@@ -41,6 +41,10 @@ pub struct AddMetadata {
     /// URL to get source code for building the image
     #[cfg_attr(feature = "clap", clap(long, value_name = "NAME"))]
     pub source: Option<Source>,
+
+    /// URL to find more information on the binary
+    #[cfg_attr(feature = "clap", clap(long, value_name = "NAME"))]
+    pub homepage: Option<Homepage>,
 }
 
 #[cfg(feature = "clap")]
@@ -62,6 +66,7 @@ impl AddMetadata {
             &self.description,
             &self.licenses,
             &self.source,
+            &self.homepage,
             input,
         )
     }
diff --git a/crates/wasm-metadata/src/lib.rs b/crates/wasm-metadata/src/lib.rs
index 287fd6e3..125c66a2 100644
--- a/crates/wasm-metadata/src/lib.rs
+++ b/crates/wasm-metadata/src/lib.rs
@@ -5,7 +5,7 @@
 pub use add_metadata::AddMetadata;
 pub use metadata::Metadata;
 pub use names::{ComponentNames, ModuleNames};
-pub use oci_annotations::{Author, Description, Licenses, Source};
+pub use oci_annotations::{Author, Description, Homepage, Licenses, Source};
 pub use payload::Payload;
 pub use producers::{Producers, ProducersField};
 
diff --git a/crates/wasm-metadata/src/metadata.rs b/crates/wasm-metadata/src/metadata.rs
index fcac7ff6..ee9c3114 100644
--- a/crates/wasm-metadata/src/metadata.rs
+++ b/crates/wasm-metadata/src/metadata.rs
@@ -1,7 +1,7 @@
 use serde_derive::Serialize;
 use std::ops::Range;
 
-use crate::{Author, Description, Licenses, Producers, Source};
+use crate::{Author, Description, Homepage, Licenses, Producers, Source};
 
 /// Metadata associated with a Wasm Component or Module
 #[derive(Debug, Serialize, Default)]
@@ -19,6 +19,8 @@ pub struct Metadata {
     pub licenses: Option<Licenses>,
     /// URL to get source code for building the image
     pub source: Option<Source>,
+    /// URL to find more information on the binary
+    pub homepage: Option<Homepage>,
     /// Byte range of the module in the parent binary
     pub range: Range<usize>,
 }
diff --git a/crates/wasm-metadata/src/oci_annotations/homepage.rs b/crates/wasm-metadata/src/oci_annotations/homepage.rs
new file mode 100644
index 00000000..ab26ea27
--- /dev/null
+++ b/crates/wasm-metadata/src/oci_annotations/homepage.rs
@@ -0,0 +1,118 @@
+use std::borrow::Cow;
+use std::fmt::{self, Display};
+use std::str::FromStr;
+
+use anyhow::{ensure, Error, Result};
+use serde::Serialize;
+use url::Url;
+use wasm_encoder::{ComponentSection, CustomSection, Encode, Section};
+use wasmparser::CustomSectionReader;
+
+/// URL to find more information on the binary
+#[derive(Debug, Clone, PartialEq)]
+pub struct Homepage(CustomSection<'static>);
+
+impl Homepage {
+    /// Create a new instance of `Homepage`.
+    pub fn new(s: &str) -> Result<Self> {
+        Ok(Url::parse(s)?.into())
+    }
+
+    /// Parse a `homepage` custom section from a wasm binary.
+    pub(crate) fn parse_custom_section(reader: &CustomSectionReader<'_>) -> Result<Self> {
+        ensure!(
+            reader.name() == "homepage",
+            "The `homepage` custom section should have a name of 'homepage'"
+        );
+        let data = String::from_utf8(reader.data().to_owned())?;
+        Self::new(&data)
+    }
+}
+
+impl FromStr for Homepage {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Self::new(s)
+    }
+}
+
+impl From<Url> for Homepage {
+    fn from(expression: Url) -> Self {
+        Self(CustomSection {
+            name: "homepage".into(),
+            data: Cow::Owned(expression.to_string().into_bytes()),
+        })
+    }
+}
+
+impl Serialize for Homepage {
+    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        serializer.serialize_str(&self.to_string())
+    }
+}
+
+impl Display for Homepage {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        // NOTE: this will never panic since we always guarantee the data is
+        // encoded as utf8, even if we internally store it as [u8].
+        let data = String::from_utf8(self.0.data.to_vec()).unwrap();
+        write!(f, "{data}")
+    }
+}
+
+impl ComponentSection for Homepage {
+    fn id(&self) -> u8 {
+        ComponentSection::id(&self.0)
+    }
+}
+
+impl Section for Homepage {
+    fn id(&self) -> u8 {
+        Section::id(&self.0)
+    }
+}
+
+impl Encode for Homepage {
+    fn encode(&self, sink: &mut Vec<u8>) {
+        self.0.encode(sink);
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+    use wasm_encoder::Component;
+    use wasmparser::Payload;
+
+    #[test]
+    fn roundtrip() {
+        let mut component = Component::new();
+        component
+            .section(&Homepage::new("https://github.com/bytecodealliance/wasm-tools").unwrap());
+        let component = component.finish();
+
+        let mut parsed = false;
+        for section in wasmparser::Parser::new(0).parse_all(&component) {
+            if let Payload::CustomSection(reader) = section.unwrap() {
+                let description = Homepage::parse_custom_section(&reader).unwrap();
+                assert_eq!(
+                    description.to_string(),
+                    "https://github.com/bytecodealliance/wasm-tools"
+                );
+                parsed = true;
+            }
+        }
+        assert!(parsed);
+    }
+
+    #[test]
+    fn serialize() {
+        let description = Homepage::new("https://github.com/bytecodealliance/wasm-tools").unwrap();
+        let json = serde_json::to_string(&description).unwrap();
+        assert_eq!(r#""https://github.com/bytecodealliance/wasm-tools""#, json);
+    }
+}
diff --git a/crates/wasm-metadata/src/oci_annotations/mod.rs b/crates/wasm-metadata/src/oci_annotations/mod.rs
index bbd01aef..760c3ed5 100644
--- a/crates/wasm-metadata/src/oci_annotations/mod.rs
+++ b/crates/wasm-metadata/src/oci_annotations/mod.rs
@@ -17,10 +17,12 @@
 
 pub use author::Author;
 pub use description::Description;
+pub use homepage::Homepage;
 pub use licenses::Licenses;
 pub use source::Source;
 
 mod author;
 mod description;
+mod homepage;
 mod licenses;
 mod source;
diff --git a/crates/wasm-metadata/src/payload.rs b/crates/wasm-metadata/src/payload.rs
index 5560373d..6acb22aa 100644
--- a/crates/wasm-metadata/src/payload.rs
+++ b/crates/wasm-metadata/src/payload.rs
@@ -6,7 +6,8 @@ use serde_derive::Serialize;
 use wasmparser::{KnownCustom, Parser, Payload::*};
 
 use crate::{
-    Author, ComponentNames, Description, Licenses, Metadata, ModuleNames, Producers, Source,
+    Author, ComponentNames, Description, Homepage, Licenses, Metadata, ModuleNames, Producers,
+    Source,
 };
 
 /// Data representing either a Wasm Component or module
@@ -125,6 +126,14 @@ impl Payload {
                             .metadata_mut();
                         *source = Some(a);
                     }
+                    KnownCustom::Unknown if c.name() == "homepage" => {
+                        let a = Homepage::parse_custom_section(&c)?;
+                        let Metadata { homepage, .. } = output
+                            .last_mut()
+                            .expect("non-empty metadata stack")
+                            .metadata_mut();
+                        *homepage = Some(a);
+                    }
                     _ => {}
                 },
                 _ => {}
diff --git a/crates/wasm-metadata/src/producers.rs b/crates/wasm-metadata/src/producers.rs
index fca295ad..104e7498 100644
--- a/crates/wasm-metadata/src/producers.rs
+++ b/crates/wasm-metadata/src/producers.rs
@@ -148,7 +148,7 @@ impl Producers {
     /// Merge into an existing wasm module. Rewrites the module with this producers section
     /// merged into its existing one, or adds this producers section if none is present.
     pub fn add_to_wasm(&self, input: &[u8]) -> Result<Vec<u8>> {
-        rewrite_wasm(&None, self, &None, &None, &None, &None, input)
+        rewrite_wasm(&None, self, &None, &None, &None, &None, &None, input)
     }
 
     pub(crate) fn display(&self, f: &mut fmt::Formatter, indent: usize) -> fmt::Result {
diff --git a/crates/wasm-metadata/src/rewrite.rs b/crates/wasm-metadata/src/rewrite.rs
index 43ea44f9..7611ebb0 100644
--- a/crates/wasm-metadata/src/rewrite.rs
+++ b/crates/wasm-metadata/src/rewrite.rs
@@ -1,4 +1,6 @@
-use crate::{Author, ComponentNames, Description, Licenses, ModuleNames, Producers, Source};
+use crate::{
+    Author, ComponentNames, Description, Homepage, Licenses, ModuleNames, Producers, Source,
+};
 use anyhow::Result;
 use std::mem;
 use wasm_encoder::ComponentSection as _;
@@ -12,6 +14,7 @@ pub(crate) fn rewrite_wasm(
     add_description: &Option<Description>,
     add_licenses: &Option<Licenses>,
     add_source: &Option<Source>,
+    add_homepage: &Option<Homepage>,
     input: &[u8],
 ) -> Result<Vec<u8>> {
     let mut producers_found = false;
@@ -106,6 +109,13 @@ pub(crate) fn rewrite_wasm(
                             continue;
                         }
                     }
+                    KnownCustom::Unknown if c.name() == "homepage" => {
+                        if add_source.is_none() {
+                            let homepage = Homepage::parse_custom_section(c)?;
+                            homepage.append_to(&mut output);
+                            continue;
+                        }
+                    }
                     _ => {}
                 }
             }
@@ -147,5 +157,8 @@ pub(crate) fn rewrite_wasm(
     if let Some(source) = add_source {
         source.append_to(&mut output);
     }
+    if let Some(homepage) = add_homepage {
+        homepage.append_to(&mut output);
+    }
     Ok(output)
 }


Test patch:
diff --git a/crates/wasm-metadata/tests/component.rs b/crates/wasm-metadata/tests/component.rs
index 6eba7d46..db850d56 100644
--- a/crates/wasm-metadata/tests/component.rs
+++ b/crates/wasm-metadata/tests/component.rs
@@ -17,6 +17,7 @@ fn add_to_empty_component() {
             Licenses::new("Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT").unwrap(),
         ),
         source: Some(Source::new("https://github.com/bytecodealliance/wasm-tools").unwrap()),
+        homepage: Some(Homepage::new("https://github.com/bytecodealliance/wasm-tools").unwrap()),
     };
     let component = add.to_wasm(&component).unwrap();
 
@@ -32,6 +33,7 @@ fn add_to_empty_component() {
                     licenses,
                     source,
                     range,
+                    homepage,
                 },
         } => {
             assert!(children.is_empty());
@@ -56,9 +58,13 @@ fn add_to_empty_component() {
                 source.unwrap(),
                 Source::new("https://github.com/bytecodealliance/wasm-tools").unwrap(),
             );
+            assert_eq!(
+                homepage.unwrap(),
+                Homepage::new("https://github.com/bytecodealliance/wasm-tools").unwrap(),
+            );
 
             assert_eq!(range.start, 0);
-            assert_eq!(range.end, 251);
+            assert_eq!(range.end, 308);
         }
         _ => panic!("metadata should be component"),
     }
@@ -79,6 +85,7 @@ fn add_to_nested_component() {
             Licenses::new("Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT").unwrap(),
         ),
         source: Some(Source::new("https://github.com/bytecodealliance/wasm-tools").unwrap()),
+        homepage: Some(Homepage::new("https://github.com/bytecodealliance/wasm-tools").unwrap()),
     };
     let module = add.to_wasm(&module).unwrap();
 
@@ -124,6 +131,7 @@ fn add_to_nested_component() {
                     source,
                     range,
                     description,
+                    homepage,
                 }) => {
                     assert_eq!(name, &Some("foo".to_owned()));
                     let producers = producers.as_ref().expect("some producers");
@@ -151,9 +159,16 @@ fn add_to_nested_component() {
                             Source::new("https://github.com/bytecodealliance/wasm-tools").unwrap()
                         ),
                     );
+                    assert_eq!(
+                        homepage,
+                        &Some(
+                            Homepage::new("https://github.com/bytecodealliance/wasm-tools")
+                                .unwrap()
+                        ),
+                    );
 
                     assert_eq!(range.start, 11);
-                    assert_eq!(range.end, 252);
+                    assert_eq!(range.end, 309);
                 }
                 _ => panic!("child is a module"),
             }
diff --git a/crates/wasm-metadata/tests/module.rs b/crates/wasm-metadata/tests/module.rs
index 91c064d3..662d801f 100644
--- a/crates/wasm-metadata/tests/module.rs
+++ b/crates/wasm-metadata/tests/module.rs
@@ -17,6 +17,7 @@ fn add_to_empty_module() {
             Licenses::new("Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT").unwrap(),
         ),
         source: Some(Source::new("https://github.com/bytecodealliance/wasm-tools").unwrap()),
+        homepage: Some(Homepage::new("https://github.com/bytecodealliance/wasm-tools").unwrap()),
     };
     let module = add.to_wasm(&module).unwrap();
 
@@ -29,6 +30,7 @@ fn add_to_empty_module() {
             source,
             range,
             description,
+            homepage,
         }) => {
             assert_eq!(name, Some("foo".to_owned()));
             let producers = producers.expect("some producers");
@@ -51,9 +53,13 @@ fn add_to_empty_module() {
                 source.unwrap(),
                 Source::new("https://github.com/bytecodealliance/wasm-tools").unwrap(),
             );
+            assert_eq!(
+                homepage.unwrap(),
+                Homepage::new("https://github.com/bytecodealliance/wasm-tools").unwrap(),
+            );
 
             assert_eq!(range.start, 0);
-            assert_eq!(range.end, 241);
+            assert_eq!(range.end, 298);
         }
         _ => panic!("metadata should be module"),
     }