[java-opensaml] branch main updated: OSJ-439 - Add options for removal to Algorithm metadata filter
Codeberg
noreply at shibboleth.net
Wed Jan 21 17:45:16 UTC 2026
This is an automated email from the git hooks/post-receive script.
codeberg pushed a commit to branch main
in repository java-opensaml.
View the commit online:
https://codeberg.org/Shibboleth/java-opensaml/commit/163c8e016047ed7fbfd10f4996054392aa787be5
The following commit(s) were added to refs/heads/main by this push:
new 163c8e016 OSJ-439 - Add options for removal to Algorithm metadata filter
163c8e016 is described below
commit 163c8e016047ed7fbfd10f4996054392aa787be5
Author: Scott Cantor <cantor.2 at osu.edu>
AuthorDate: Wed Jan 21 12:45:05 2026 -0500
OSJ-439 - Add options for removal to Algorithm metadata filter
https://shibboleth.atlassian.net/browse/OSJ-439
Added 3 options for individual control of removal.
---
.../resolver/filter/impl/AlgorithmFilter.java | 156 ++++++++++++++++++---
.../resolver/filter/impl/AlgorithmFilterTest.java | 135 +++++++++++++++++-
.../filter/impl/EntityDescriptorWithAlgorithms.xml | 4 +
3 files changed, 272 insertions(+), 23 deletions(-)
diff --git a/opensaml-saml-impl/src/main/java/org/opensaml/saml/metadata/resolver/filter/impl/AlgorithmFilter.java b/opensaml-saml-impl/src/main/java/org/opensaml/saml/metadata/resolver/filter/impl/AlgorithmFilter.java
index 4d7ec6b1b..563c8a5ea 100644
--- a/opensaml-saml-impl/src/main/java/org/opensaml/saml/metadata/resolver/filter/impl/AlgorithmFilter.java
+++ b/opensaml-saml-impl/src/main/java/org/opensaml/saml/metadata/resolver/filter/impl/AlgorithmFilter.java
@@ -29,6 +29,7 @@ import org.opensaml.core.xml.XMLObject;
import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
import org.opensaml.core.xml.io.MarshallingException;
import org.opensaml.core.xml.io.UnmarshallingException;
+import org.opensaml.core.xml.schema.XSString;
import org.opensaml.core.xml.util.XMLObjectSupport;
import org.opensaml.saml.common.SAMLObjectBuilder;
import org.opensaml.saml.ext.saml2alg.DigestMethod;
@@ -64,26 +65,73 @@ import net.shibboleth.shared.primitive.LoggerFactory;
*/
public class AlgorithmFilter extends AbstractMetadataFilter {
+ /** Used as a value in place of an empty collection when removing only. */
+ @Nonnull @NotEmpty private static final String GUARD_VALUE = "_EMPTY";
+
/** Class logger. */
@Nonnull private final Logger log = LoggerFactory.getLogger(AlgorithmFilter.class);
/** Registry for sanity checking algorithms. */
@Nullable private AlgorithmRegistry registry = AlgorithmSupport.getGlobalAlgorithmRegistry();
+ /** Flag controlling removal of existing DigestMethod extensions. */
+ private boolean removeExistingDigestMethods;
+
+ /** Flag controlling removal of existing SigningMethod extensions. */
+ private boolean removeExistingSigningMethods;
+
+ /** Flag controlling removal of existing EncryptionMethod extensions. */
+ private boolean removeExistingEncryptionMethods;
+
/** Rules for adding algorithms. */
@Nonnull private Multimap<Predicate<EntityDescriptor>,XMLObject> applyMap;
/** Builder for {@link Extensions}. */
@Nonnull private final SAMLObjectBuilder<Extensions> extBuilder;
+
+ /** Stands in for an empty collection when removing only. */
+ @Nonnull private final XSString guardObject;
/** Constructor. */
public AlgorithmFilter() {
+ guardObject = XMLObjectProviderRegistrySupport.getBuilderFactory().<XSString>ensureBuilder(
+ XSString.TYPE_NAME).buildObject(null, GUARD_VALUE, null);
+ guardObject.setValue(GUARD_VALUE);
extBuilder = (SAMLObjectBuilder<Extensions>)
XMLObjectProviderRegistrySupport.getBuilderFactory().<Extensions>ensureBuilder(
Extensions.DEFAULT_ELEMENT_NAME);
applyMap = ArrayListMultimap.create();
}
+ /**
+ * Sets whether to remove existing DigestMethod extensions.
+ *
+ * @param flag flag to set
+ */
+ public void setRemoveExistingDigestMethods(final boolean flag) {
+ checkSetterPreconditions();
+ removeExistingDigestMethods = flag;
+ }
+
+ /**
+ * Sets whether to remove existing SigningMethod extensions.
+ *
+ * @param flag flag to set
+ */
+ public void setRemoveExistingSigningMethods(final boolean flag) {
+ checkSetterPreconditions();
+ removeExistingSigningMethods = flag;
+ }
+
+ /**
+ * Sets whether to remove existing EncryptionMethod extensions.
+ *
+ * @param flag flag to set
+ */
+ public void setRemoveExistingEncryptionMethods(final boolean flag) {
+ checkSetterPreconditions();
+ removeExistingEncryptionMethods = flag;
+ }
// Checkstyle: CyclomaticComplexity OFF
/**
@@ -101,7 +149,7 @@ public class AlgorithmFilter extends AbstractMetadataFilter {
.map(DigestMethod.class::cast)
.map(DigestMethod::getAlgorithm)
.distinct()
- .forEach(uri -> checkDigestMethod(uri));
+ .forEach(this::checkDigestMethod);
rules.values().stream()
.flatMap(Collection::stream)
@@ -109,7 +157,7 @@ public class AlgorithmFilter extends AbstractMetadataFilter {
.map(SigningMethod.class::cast)
.map(SigningMethod::getAlgorithm)
.distinct()
- .forEach(uri -> checkSigningMethod(uri));
+ .forEach(this::checkSigningMethod);
rules.values().stream()
.flatMap(Collection::stream)
@@ -117,12 +165,14 @@ public class AlgorithmFilter extends AbstractMetadataFilter {
.map(EncryptionMethod.class::cast)
.map(EncryptionMethod::getAlgorithm)
.distinct()
- .forEach(uri -> checkEncryptionMethod(uri));
+ .forEach(this::checkEncryptionMethod);
applyMap = ArrayListMultimap.create(rules.size(), 1);
for (final Map.Entry<Predicate<EntityDescriptor>,Collection<XMLObject>> entry : rules.entrySet()) {
if (entry.getKey() != null && entry.getValue() != null) {
- applyMap.putAll(entry.getKey(), List.copyOf(entry.getValue()));
+ applyMap.putAll(entry.getKey(),
+ entry.getValue().isEmpty() ? CollectionSupport.singletonList(guardObject)
+ : CollectionSupport.copyToList(entry.getValue()));
}
}
}
@@ -153,31 +203,43 @@ public class AlgorithmFilter extends AbstractMetadataFilter {
* @param descriptor entity descriptor to filter
*/
protected void filterEntityDescriptor(@Nonnull final EntityDescriptor descriptor) {
-
+
Set<String> existingDigests = CollectionSupport.emptySet();
Set<String> existingSignings = CollectionSupport.emptySet();
final Extensions exts = descriptor.getExtensions();
if (exts != null) {
- existingDigests = exts.getUnknownXMLObjects(DigestMethod.DEFAULT_ELEMENT_NAME)
- .stream()
- .filter(DigestMethod.class::isInstance)
- .map(DigestMethod.class::cast)
- .map(DigestMethod::getAlgorithm)
- .distinct()
- .collect(Collectors.toUnmodifiableSet());
- existingSignings = exts.getUnknownXMLObjects(SigningMethod.DEFAULT_ELEMENT_NAME)
- .stream()
- .filter(SigningMethod.class::isInstance)
- .map(SigningMethod.class::cast)
- .map(SigningMethod::getAlgorithm)
- .distinct()
- .collect(Collectors.toUnmodifiableSet());
+ if (!removeExistingDigestMethods) {
+ existingDigests = exts.getUnknownXMLObjects(DigestMethod.DEFAULT_ELEMENT_NAME)
+ .stream()
+ .filter(DigestMethod.class::isInstance)
+ .map(DigestMethod.class::cast)
+ .map(DigestMethod::getAlgorithm)
+ .distinct()
+ .collect(Collectors.toUnmodifiableSet());
+ }
+ if (!removeExistingSigningMethods) {
+ existingSignings = exts.getUnknownXMLObjects(SigningMethod.DEFAULT_ELEMENT_NAME)
+ .stream()
+ .filter(SigningMethod.class::isInstance)
+ .map(SigningMethod.class::cast)
+ .map(SigningMethod::getAlgorithm)
+ .distinct()
+ .collect(Collectors.toUnmodifiableSet());
+ }
}
for (final Map.Entry<Predicate<EntityDescriptor>,Collection<XMLObject>> entry : applyMap.asMap().entrySet()) {
- if (!entry.getValue().isEmpty() && entry.getKey().test(descriptor)) {
+ if (entry.getKey().test(descriptor)) {
+
+ // Only acts if needed.
+ removeExistingMethods(descriptor);
for (final XMLObject xmlObject : entry.getValue()) {
+ // Check for guard object to break loop since we were only removing.
+ if (xmlObject instanceof XSString) {
+ break;
+ }
+
try {
if (xmlObject instanceof DigestMethod) {
if (existingDigests.contains(((DigestMethod) xmlObject).getAlgorithm())) {
@@ -251,6 +313,58 @@ public class AlgorithmFilter extends AbstractMetadataFilter {
return extensions;
}
+// Checkstyle: CyclomaticComplexity OFF
+ /**
+ * Performs removal of existing methods if directed.
+ *
+ * @param descriptor descriptor to filter
+ */
+ protected void removeExistingMethods(@Nonnull final EntityDescriptor descriptor) {
+ Extensions exts = descriptor.getExtensions();
+ // Remove existing objects first (if directed).
+ if (exts != null) {
+ if (removeExistingDigestMethods) {
+ log.debug("Clearing existing entity-level DigestMethod extensions on EntityDescriptor '{}'",
+ descriptor.getEntityID());
+ exts.getUnknownXMLObjects(DigestMethod.DEFAULT_ELEMENT_NAME).clear();
+ }
+ if (removeExistingSigningMethods) {
+ log.debug("Clearing existing entity-level SigningMethod extensions on EntityDescriptor '{}'",
+ descriptor.getEntityID());
+ exts.getUnknownXMLObjects(SigningMethod.DEFAULT_ELEMENT_NAME).clear();
+ }
+ }
+
+ if (removeExistingDigestMethods || removeExistingSigningMethods || removeExistingEncryptionMethods) {
+ for (final RoleDescriptor role : descriptor.getRoleDescriptors()) {
+ exts = role.getExtensions();
+ if (exts != null) {
+ if (removeExistingDigestMethods) {
+ log.debug("Clearing existing role-level DigestMethod extensions on EntityDescriptor '{}'",
+ descriptor.getEntityID());
+ exts.getUnknownXMLObjects(DigestMethod.DEFAULT_ELEMENT_NAME).clear();
+ }
+ if (removeExistingSigningMethods) {
+ log.debug("Clearing existing role-level SigningMethod extensions on EntityDescriptor '{}'",
+ descriptor.getEntityID());
+ exts.getUnknownXMLObjects(SigningMethod.DEFAULT_ELEMENT_NAME).clear();
+ }
+ }
+ if (removeExistingEncryptionMethods) {
+ for (final KeyDescriptor key : role.getKeyDescriptors()) {
+ if (key.getUse() == null || key.getUse() != UsageType.SIGNING) {
+ log.debug(
+ "Clearing existing key-level EncryptionMethod extensions on EntityDescriptor '{}'",
+ descriptor.getEntityID());
+ key.getEncryptionMethods().clear();
+ }
+ }
+ }
+ }
+ }
+ }
+ // Checkstyle: CyclomaticComplexity ON
+
/**
* Add {@link EncryptionMethod} extension to every {@link KeyDescriptor} found in
* an entity.
@@ -280,7 +394,7 @@ public class AlgorithmFilter extends AbstractMetadataFilter {
encryptionMethod.getAlgorithm(), descriptor.getEntityID());
existingMethods.add(XMLObjectSupport.cloneXMLObject(encryptionMethod));
} catch (final MarshallingException|UnmarshallingException e) {
- log.error("Error cloning XMLObject", e);
+ log.error("Error cloning EncryptionMethod", e);
}
}
}
diff --git a/opensaml-saml-impl/src/test/java/org/opensaml/saml/metadata/resolver/filter/impl/AlgorithmFilterTest.java b/opensaml-saml-impl/src/test/java/org/opensaml/saml/metadata/resolver/filter/impl/AlgorithmFilterTest.java
index 00b35bbb5..62e07dbe7 100644
--- a/opensaml-saml-impl/src/test/java/org/opensaml/saml/metadata/resolver/filter/impl/AlgorithmFilterTest.java
+++ b/opensaml-saml-impl/src/test/java/org/opensaml/saml/metadata/resolver/filter/impl/AlgorithmFilterTest.java
@@ -43,6 +43,7 @@ import org.opensaml.saml.saml2.metadata.RoleDescriptor;
import org.opensaml.xmlsec.encryption.MGF;
import org.opensaml.xmlsec.encryption.support.EncryptionConstants;
import org.opensaml.xmlsec.signature.support.SignatureConstants;
+import org.testng.Assert;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
@@ -102,7 +103,8 @@ public class AlgorithmFilterTest extends XMLObjectBaseTestCase implements Predic
final EncryptionMethod enc3 = buildXMLObject(EncryptionMethod.DEFAULT_ELEMENT_NAME);
enc3.setAlgorithm(EncryptionConstants.ALGO_ID_BLOCKCIPHER_AES256);
- final Collection<XMLObject> algs = List.of(digest1, digest2, signing1, signing2, enc1, digest3, signing3, enc2, enc3);
+ final Collection<XMLObject> algs =
+ CollectionSupport.listOf(digest1, digest2, signing1, signing2, enc1, digest3, signing3, enc2, enc3);
final AlgorithmFilter filter = new AlgorithmFilter();
filter.setRules(CollectionSupport.singletonMap(this, algs));
@@ -115,7 +117,7 @@ public class AlgorithmFilterTest extends XMLObjectBaseTestCase implements Predic
EntityIdCriterion crit = new EntityIdCriterion("https://foo.example.org/sp");
EntityDescriptor entity = metadataProvider.resolveSingle(new CriteriaSet(crit));
assert entity != null;
- final Extensions exts = entity.getExtensions();
+ Extensions exts = entity.getExtensions();
assert exts != null;
List<XMLObject> extElements = exts.getUnknownXMLObjects(DigestMethod.DEFAULT_ELEMENT_NAME);
@@ -135,6 +137,10 @@ public class AlgorithmFilterTest extends XMLObjectBaseTestCase implements Predic
assertEquals(((SigningMethod) signings.next()).getAlgorithm(), "foo");
for (final RoleDescriptor role : entity.getRoleDescriptors()) {
+ exts = role.getExtensions();
+ Assert.assertEquals(exts.getUnknownXMLObjects(DigestMethod.DEFAULT_ELEMENT_NAME).size(), 1);
+ Assert.assertEquals(exts.getUnknownXMLObjects(SigningMethod.DEFAULT_ELEMENT_NAME).size(), 1);
+
for (final KeyDescriptor key : role.getKeyDescriptors()) {
final List<EncryptionMethod> methods = key.getEncryptionMethods();
assertEquals(methods.size(), 3);
@@ -154,7 +160,132 @@ public class AlgorithmFilterTest extends XMLObjectBaseTestCase implements Predic
}
}
}
+
+ @Test
+ public void testWithRemoval() throws ComponentInitializationException, ResolverException {
+
+ final DigestMethod digest2 = buildXMLObject(DigestMethod.DEFAULT_ELEMENT_NAME);
+ digest2.setAlgorithm(SignatureConstants.ALGO_ID_DIGEST_SHA512);
+
+ final DigestMethod digest3 = buildXMLObject(DigestMethod.DEFAULT_ELEMENT_NAME);
+ digest3.setAlgorithm("foo");
+
+ final SigningMethod signing2 = buildXMLObject(SigningMethod.DEFAULT_ELEMENT_NAME);
+ signing2.setAlgorithm(SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA512);
+
+ final SigningMethod signing3 = buildXMLObject(SigningMethod.DEFAULT_ELEMENT_NAME);
+ signing3.setAlgorithm("foo");
+
+ final EncryptionMethod enc1 = buildXMLObject(EncryptionMethod.DEFAULT_ELEMENT_NAME);
+ enc1.setAlgorithm(EncryptionConstants.ALGO_ID_KEYTRANSPORT_RSAOAEP11);
+ final org.opensaml.xmlsec.signature.DigestMethod embeddedDigest =
+ buildXMLObject(org.opensaml.xmlsec.signature.DigestMethod.DEFAULT_ELEMENT_NAME);
+ embeddedDigest.setAlgorithm(SignatureConstants.ALGO_ID_DIGEST_SHA256);
+ enc1.getUnknownXMLObjects().add(embeddedDigest);
+
+ final MGF mgf = buildXMLObject(MGF.DEFAULT_ELEMENT_NAME);
+ mgf.setAlgorithm(EncryptionConstants.ALGO_ID_MGF1_SHA256);
+ enc1.getUnknownXMLObjects().add(mgf);
+
+ final EncryptionMethod enc2 = buildXMLObject(EncryptionMethod.DEFAULT_ELEMENT_NAME);
+ enc2.setAlgorithm("foo");
+
+ final Collection<XMLObject> algs =
+ CollectionSupport.listOf(digest2, signing2, enc1, digest3, signing3, enc2);
+
+ final AlgorithmFilter filter = new AlgorithmFilter();
+ filter.setRemoveExistingDigestMethods(true);
+ filter.setRemoveExistingSigningMethods(true);
+ filter.setRemoveExistingEncryptionMethods(true);
+ filter.setRules(CollectionSupport.singletonMap(this, algs));
+ filter.initialize();
+
+ metadataProvider.setMetadataFilter(filter);
+ metadataProvider.setId("test");
+ metadataProvider.initialize();
+
+ EntityIdCriterion crit = new EntityIdCriterion("https://foo.example.org/sp");
+ EntityDescriptor entity = metadataProvider.resolveSingle(new CriteriaSet(crit));
+ assert entity != null;
+ Extensions exts = entity.getExtensions();
+ assert exts != null;
+
+ List<XMLObject> extElements = exts.getUnknownXMLObjects(DigestMethod.DEFAULT_ELEMENT_NAME);
+ assertEquals(extElements.size(), 2);
+
+ Iterator<XMLObject> digests = extElements.iterator();
+ assertEquals(((DigestMethod) digests.next()).getAlgorithm(), SignatureConstants.ALGO_ID_DIGEST_SHA512);
+ assertEquals(((DigestMethod) digests.next()).getAlgorithm(), "foo");
+
+ extElements = exts.getUnknownXMLObjects(SigningMethod.DEFAULT_ELEMENT_NAME);
+ assertEquals(extElements.size(), 2);
+
+ Iterator<XMLObject> signings = extElements.iterator();
+ assertEquals(((SigningMethod) signings.next()).getAlgorithm(), SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA512);
+ assertEquals(((SigningMethod) signings.next()).getAlgorithm(), "foo");
+
+ for (final RoleDescriptor role : entity.getRoleDescriptors()) {
+ exts = role.getExtensions();
+ if (exts != null) {
+ Assert.assertTrue(exts.getUnknownXMLObjects(DigestMethod.DEFAULT_ELEMENT_NAME).isEmpty());
+ Assert.assertTrue(exts.getUnknownXMLObjects(SigningMethod.DEFAULT_ELEMENT_NAME).isEmpty());
+ }
+
+ for (final KeyDescriptor key : role.getKeyDescriptors()) {
+ final List<EncryptionMethod> methods = key.getEncryptionMethods();
+ assertEquals(methods.size(), 2);
+ assertEquals(methods.get(0).getAlgorithm(), EncryptionConstants.ALGO_ID_KEYTRANSPORT_RSAOAEP11);
+ assertEquals(methods.get(1).getAlgorithm(), "foo");
+
+ final List<XMLObject> encDigests = methods.get(0).getUnknownXMLObjects(
+ org.opensaml.xmlsec.signature.DigestMethod.DEFAULT_ELEMENT_NAME);
+ assertEquals(encDigests.size(), 1);
+ assertEquals(((org.opensaml.xmlsec.signature.DigestMethod) encDigests.get(0)).getAlgorithm(),
+ SignatureConstants.ALGO_ID_DIGEST_SHA256);
+
+ final List<XMLObject> mgfs = methods.get(0).getUnknownXMLObjects(MGF.DEFAULT_ELEMENT_NAME);
+ assertEquals(mgfs.size(), 1);
+ assertEquals(((MGF) mgfs.get(0)).getAlgorithm(), EncryptionConstants.ALGO_ID_MGF1_SHA256);
+ }
+ }
+ }
+
+ @Test
+ public void testRemovalOnly() throws ComponentInitializationException, ResolverException {
+
+ final AlgorithmFilter filter = new AlgorithmFilter();
+ filter.setRemoveExistingDigestMethods(true);
+ filter.setRemoveExistingSigningMethods(true);
+ filter.setRemoveExistingEncryptionMethods(true);
+ filter.setRules(CollectionSupport.singletonMap(this, CollectionSupport.emptyList()));
+ filter.initialize();
+
+ metadataProvider.setMetadataFilter(filter);
+ metadataProvider.setId("test");
+ metadataProvider.initialize();
+
+ EntityIdCriterion crit = new EntityIdCriterion("https://foo.example.org/sp");
+ EntityDescriptor entity = metadataProvider.resolveSingle(new CriteriaSet(crit));
+ assert entity != null;
+ Extensions exts = entity.getExtensions();
+ if (exts != null) {
+ Assert.assertTrue(exts.getUnknownXMLObjects(DigestMethod.DEFAULT_ELEMENT_NAME).isEmpty());
+ Assert.assertTrue(exts.getUnknownXMLObjects(SigningMethod.DEFAULT_ELEMENT_NAME).isEmpty());
+ }
+
+ for (final RoleDescriptor role : entity.getRoleDescriptors()) {
+ exts = entity.getExtensions();
+ if (exts != null) {
+ Assert.assertTrue(exts.getUnknownXMLObjects(DigestMethod.DEFAULT_ELEMENT_NAME).isEmpty());
+ Assert.assertTrue(exts.getUnknownXMLObjects(SigningMethod.DEFAULT_ELEMENT_NAME).isEmpty());
+ }
+ for (final KeyDescriptor key : role.getKeyDescriptors()) {
+ Assert.assertTrue(key.getEncryptionMethods().isEmpty());
+ }
+ }
+ }
+
/** {@inheritDoc} */
public boolean test(final EntityDescriptor input) {
return input != null && "https://foo.example.org/sp".equals(input.getEntityID());
diff --git a/opensaml-saml-impl/src/test/resources/org/opensaml/saml/metadata/resolver/filter/impl/EntityDescriptorWithAlgorithms.xml b/opensaml-saml-impl/src/test/resources/org/opensaml/saml/metadata/resolver/filter/impl/EntityDescriptorWithAlgorithms.xml
index cb24fb4ef..a8aba3d3f 100644
--- a/opensaml-saml-impl/src/test/resources/org/opensaml/saml/metadata/resolver/filter/impl/EntityDescriptorWithAlgorithms.xml
+++ b/opensaml-saml-impl/src/test/resources/org/opensaml/saml/metadata/resolver/filter/impl/EntityDescriptorWithAlgorithms.xml
@@ -6,6 +6,10 @@
<alg:SigningMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" />
</md:Extensions>
<md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
+ <md:Extensions>
+ <alg:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />
+ <alg:SigningMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" />
+ </md:Extensions>
<md:KeyDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">
<md:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc" />
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list