All of lore.kernel.org
 help / color / mirror / Atom feed
From: Christian Brauner <brauner@kernel.org>
To: linux-fsdevel@vger.kernel.org
Cc: Alexander Viro <viro@zeniv.linux.org.uk>, Jan Kara <jack@suse.cz>,
	 Kees Cook <kees@kernel.org>,
	linux-mm@kvack.org, bpf@vger.kernel.org,
	 Jonathan Corbet <corbet@lwn.net>,
	Farid Zakaria <farid.m.zakaria@gmail.com>,
	 Daniel Borkmann <daniel@iogearbox.net>,
	Alexei Starovoitov <ast@kernel.org>,
	 jannh@google.com, mail@johnericson.me,
	 "Christian Brauner (Amutable)" <brauner@kernel.org>
Subject: [PATCH 8/9] selftests/exec: test interpreters bound to a 'B' entry
Date: Thu, 30 Jul 2026 15:34:10 +0200	[thread overview]
Message-ID: <20260730-work-binfmt_misc-preopen-v1-8-4a0b0da71f16@kernel.org> (raw)
In-Reply-To: <20260730-work-binfmt_misc-preopen-v1-0-4a0b0da71f16@kernel.org>

One handler, one entry registered disabled, an interpreter per guest
architecture bound to a file one write at a time. The load program picks
one by name per exec:

- an aarch64 binary runs the interpreter bound as "first" and a riscv one
  the interpreter bound as "second", from a single entry and a single
  handler

- unlinking a bound interpreter and putting a different binary in its
  place changes nothing, which is what the binding exists for

- the entry reports what it bound, under the names it bound them as

- a name the entry did not bind fails the exec with -ENOENT rather than
  falling back to anything

- activating the entry refuses further binding with -EBUSY, a later
  disable does not undo that, and an entry registered without 'D' never
  accepted a '+' write to begin with

- a name binds one interpreter, and control characters are refused

- the command has to end at the write, bytes past an embedded nul are
  refused

- an entry binds at most 100 interpreters, the next one is refused with
  -ENOSPC

The test interpreter prints its argv[0], which is the path the kernel ran
that copy under, so one binary installed at two paths tells the harness
which of them the program picked.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 tools/testing/selftests/exec/Makefile             |   7 +-
 tools/testing/selftests/exec/binfmt_bind_interp.c |  14 ++
 tools/testing/selftests/exec/binfmt_misc_bpf.c    | 261 +++++++++++++++++++++-
 tools/testing/selftests/exec/interp_bind.bpf.c    |  76 +++++++
 4 files changed, 349 insertions(+), 9 deletions(-)

diff --git a/tools/testing/selftests/exec/Makefile b/tools/testing/selftests/exec/Makefile
index ec7894a802e0..410c93606a0c 100644
--- a/tools/testing/selftests/exec/Makefile
+++ b/tools/testing/selftests/exec/Makefile
@@ -56,8 +56,8 @@ HAVE_BPF_TOOLCHAIN ?= $(shell command -v $(CLANG) >/dev/null 2>&1 && \
 ifeq ($(HAVE_BPF_TOOLCHAIN),y)
 TEST_GEN_PROGS += binfmt_misc_bpf
 TEST_GEN_FILES += bpf_interp.bpf.o nix_origin.bpf.o transparent.bpf.o
-TEST_GEN_FILES += loader.bpf.o
-TEST_GEN_FILES += binfmt_bpf_interp binfmt_bpf_app
+TEST_GEN_FILES += loader.bpf.o interp_bind.bpf.o
+TEST_GEN_FILES += binfmt_bpf_interp binfmt_bpf_app binfmt_bind_interp
 else
 $(info exec selftests: skipping binfmt_misc_bpf, needs clang, bpftool, vmlinux BTF and libbpf)
 endif
@@ -127,6 +127,9 @@ $(OUTPUT)/binfmt_misc_bpf: binfmt_misc_bpf.c binfmt_misc_common.h
 $(OUTPUT)/binfmt_bpf_interp: binfmt_bpf_interp.c
 	$(CC) $(CFLAGS) $(LDFLAGS) $< -o $@
 
+$(OUTPUT)/binfmt_bind_interp: binfmt_bind_interp.c
+	$(CC) $(CFLAGS) $(LDFLAGS) $< -o $@
+
 $(OUTPUT)/binfmt_loader_payload: binfmt_loader_payload.c binfmt_misc_common.h
 	$(CC) $(CFLAGS) $(LDFLAGS) -fPIE -pie $< -o $@
 
diff --git a/tools/testing/selftests/exec/binfmt_bind_interp.c b/tools/testing/selftests/exec/binfmt_bind_interp.c
new file mode 100644
index 000000000000..06d65062856b
--- /dev/null
+++ b/tools/testing/selftests/exec/binfmt_bind_interp.c
@@ -0,0 +1,14 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Test interpreter for the bound-interpreter case of the binfmt_misc_bpf
+ * selftest. Two copies are installed at different paths and bound to one
+ * entry under different names; printing argv[0] - the path the kernel ran
+ * this copy under - tells the harness which of them the load program picked.
+ */
+#include <stdio.h>
+
+int main(int argc, char **argv)
+{
+	printf("BIND_RAN %s\n", argc > 0 ? argv[0] : "");
+	return 0;
+}
diff --git a/tools/testing/selftests/exec/binfmt_misc_bpf.c b/tools/testing/selftests/exec/binfmt_misc_bpf.c
index 71bb6d8b4517..2c7b63075f1d 100644
--- a/tools/testing/selftests/exec/binfmt_misc_bpf.c
+++ b/tools/testing/selftests/exec/binfmt_misc_bpf.c
@@ -9,7 +9,7 @@
  *
  *     echo ':name:B::::<handler>:' > /proc/sys/fs/binfmt_misc/register
  *
- * Three self-contained cases are exercised:
+ * Five self-contained cases are exercised:
  *
  *   1. bpf_interp: the match program matches a synthetic aarch64 ELF header
  *      from the prefetched bprm->buf and the load program routes it to a
@@ -26,6 +26,11 @@
  *      (binfmt_loader_payload) runs as the main image with the selected
  *      interpreter substituted for its PT_INTERP and asserts the native
  *      identity from inside.
+ *   5. interp_bind: an entry registered disabled with 'D' is given its
+ *      interpreters one write at a time, and the load program picks one by
+ *      name per exec. Replacing what the path holds afterwards changes
+ *      nothing, which is the point of binding a file rather than resolving
+ *      a name at exec time. Enabling the entry seals it.
  *
  * The first two route to a test interpreter that prints BPF_INTERP_RAN,
  * proving the program's chosen interpreter actually ran.
@@ -54,6 +59,12 @@
 #define TRANS_EXPECT	"TRANSPARENT_OK"
 #define LOADER_INTERP	"/tmp/binfmt_loader_interp"
 #define LOADER_PATH	"/tmp/binfmt_bpf_loader.ldrtest"
+#define BIND_FIRST	"/tmp/binfmt_bind_first"
+#define BIND_SECOND	"/tmp/binfmt_bind_second"
+#define BIND_ARM_PATH	"/tmp/binfmt_bind_arm"
+#define BIND_RISCV_PATH	"/tmp/binfmt_bind_riscv"
+#define BIND_EXPECT	"BIND_RAN "
+#define BIND_MAX	100
 
 /* A minimal 64-bit little-endian ELF header, padded to the read size. */
 static int create_fake_elf(const char *path, unsigned short machine)
@@ -82,11 +93,17 @@ static int create_fake_elf(const char *path, unsigned short machine)
 	return 0;
 }
 
-static int register_entry(const char *name, const char *handler)
+/*
+ * Register a 'B' entry for @handler. With @flags "D" the entry is created
+ * disabled, which is what leaves it open to being given interpreters.
+ */
+static int register_entry(const char *name, const char *handler,
+			  const char *flags)
 {
 	char rule[PATH_MAX];
 
-	snprintf(rule, sizeof(rule), ":%s:B::::%s:", name, handler);
+	snprintf(rule, sizeof(rule), ":%s:B::::%s:%s", name, handler,
+		 flags ? flags : "");
 	return write_reg(rule);
 }
 
@@ -139,10 +156,12 @@ struct bpf_case {
 
 /*
  * Load @objfile, attach its struct_ops map @handler (which publishes the
- * handler) and activate a 'B' entry named @entry that references it.
+ * handler) and register a 'B' entry named @entry that references it, with
+ * @flags as the entry's register-string flags.
  */
-static int bpf_case_start(struct bpf_case *c, const char *objfile,
-			  const char *handler, const char *entry)
+static int bpf_case_start_flags(struct bpf_case *c, const char *objfile,
+				const char *handler, const char *entry,
+				const char *flags)
 {
 	struct bpf_map *map;
 
@@ -172,7 +191,7 @@ static int bpf_case_start(struct bpf_case *c, const char *objfile,
 		c->link = NULL;
 		goto fail;
 	}
-	if (register_entry(entry, handler)) {
+	if (register_entry(entry, handler, flags)) {
 		fprintf(stderr, "register 'B' entry '%s' failed\n", entry);
 		goto fail;
 	}
@@ -186,6 +205,12 @@ static int bpf_case_start(struct bpf_case *c, const char *objfile,
 	return -1;
 }
 
+static int bpf_case_start(struct bpf_case *c, const char *objfile,
+			  const char *handler, const char *entry)
+{
+	return bpf_case_start_flags(c, objfile, handler, entry, NULL);
+}
+
 static void bpf_case_stop(struct bpf_case *c)
 {
 	unregister(c->entry);
@@ -318,4 +343,226 @@ TEST_F(bpf_handler, loader_substitution)
 	unlink(LOADER_INTERP);
 }
 
+/* The errno an exec of @path fails with, 0 if it succeeded. */
+static int exec_errno(const char *path)
+{
+	int status;
+	pid_t pid;
+
+	pid = fork();
+	if (pid == 0) {
+		execl(path, path, (char *)NULL);
+		_exit(errno);
+	}
+	if (pid < 0 || waitpid(pid, &status, 0) != pid || !WIFEXITED(status))
+		return -1;
+	return WEXITSTATUS(status);
+}
+
+/* Install a copy of the bound-interpreter test binary at @path. */
+static int install_interp(const char *path)
+{
+	char src[PATH_MAX];
+
+	if (artifact_path(src, sizeof(src), "binfmt_bind_interp"))
+		return -1;
+	return copy_file(src, path);
+}
+
+/* Bind @path to @entry under @name, the '+' command of a disabled entry. */
+static int entry_bind(const char *entry, const char *name, const char *path)
+{
+	char cmd[PATH_MAX];
+
+	snprintf(cmd, sizeof(cmd), "+%s %s\n", name, path);
+	return entry_command(entry, cmd);
+}
+
+FIXTURE(bound_interp) {
+	char obj[PATH_MAX];
+	struct bpf_case c;
+	bool started;
+};
+
+FIXTURE_SETUP(bound_interp)
+{
+	const char *why = bpf_handler_unsupported();
+
+	if (why)
+		SKIP(return, "%s", why);
+	if (!binfmt_flag_supported('D')) {
+		ASSERT_EQ(errno, EINVAL);
+		SKIP(return, "kernel without the 'D' flag");
+	}
+
+	ASSERT_EQ(install_interp(BIND_FIRST), 0);
+	ASSERT_EQ(install_interp(BIND_SECOND), 0);
+
+	ASSERT_EQ(artifact_path(self->obj, sizeof(self->obj),
+				"interp_bind.bpf.o"), 0);
+
+	/*
+	 * Registered disabled, so it cannot be matched yet and can still be
+	 * given interpreters. Each path is resolved once, by its write(2);
+	 * from here on the entry holds the files themselves.
+	 */
+	ASSERT_EQ(bpf_case_start_flags(&self->c, self->obj, "interp_bind",
+				       "test_interp_bind", "D"), 0);
+	self->started = true;
+
+	ASSERT_EQ(entry_bind("test_interp_bind", "first", BIND_FIRST), 0);
+	ASSERT_EQ(entry_bind("test_interp_bind", "second", BIND_SECOND), 0);
+}
+
+FIXTURE_TEARDOWN(bound_interp)
+{
+	if (self->started)
+		bpf_case_stop(&self->c);
+	unlink(BIND_FIRST);
+	unlink(BIND_SECOND);
+	unlink(AARCH64_PATH);
+	unlink(BIND_RISCV_PATH);
+	unlink(BIND_ARM_PATH);
+}
+
+/* Enabling is what makes the configured entry matchable. */
+static int activate(const char *entry)
+{
+	return entry_command(entry, "1\n");
+}
+
+/* One entry, one interpreter per guest architecture, picked per exec. */
+TEST_F(bound_interp, selects_by_name)
+{
+	ASSERT_EQ(create_fake_elf(AARCH64_PATH, EM_AARCH64), 0);
+	ASSERT_EQ(create_fake_elf(BIND_RISCV_PATH, EM_RISCV), 0);
+
+	/* Disabled, so it does not match and no format claims the binary. */
+	EXPECT_EQ(exec_errno(AARCH64_PATH), ENOEXEC);
+
+	ASSERT_EQ(activate("test_interp_bind"), 0);
+	EXPECT_EQ(check_output(AARCH64_PATH, BIND_EXPECT BIND_FIRST), 0);
+	EXPECT_EQ(check_output(BIND_RISCV_PATH, BIND_EXPECT BIND_SECOND), 0);
+}
+
+/* What was bound is what runs, whatever the path holds afterwards. */
+TEST_F(bound_interp, path_no_longer_decides)
+{
+	char other[PATH_MAX];
+
+	ASSERT_EQ(create_fake_elf(AARCH64_PATH, EM_AARCH64), 0);
+	ASSERT_EQ(activate("test_interp_bind"), 0);
+
+	/* Bound interpreters are pinned against writes, exactly like 'F'. */
+	EXPECT_TRUE(write_denied(BIND_FIRST));
+
+	/* Replace the path with a different binary: a new file, new inode. */
+	ASSERT_EQ(artifact_path(other, sizeof(other), "binfmt_bpf_interp"), 0);
+	ASSERT_EQ(unlink(BIND_FIRST), 0);
+	ASSERT_EQ(copy_file(other, BIND_FIRST), 0);
+
+	EXPECT_EQ(check_output(AARCH64_PATH, BIND_EXPECT BIND_FIRST), 0);
+}
+
+/* The entry reports what it bound, under the names it bound them as. */
+TEST_F(bound_interp, entry_reports_bindings)
+{
+	EXPECT_TRUE(entry_shows("test_interp_bind",
+				"bpf-interpreter first " BIND_FIRST));
+	EXPECT_TRUE(entry_shows("test_interp_bind",
+				"bpf-interpreter second " BIND_SECOND));
+}
+
+/* Selecting a name the entry did not bind fails the exec. */
+TEST_F(bound_interp, unbound_name_fails)
+{
+	ASSERT_EQ(create_fake_elf(BIND_ARM_PATH, EM_ARM), 0);
+	ASSERT_EQ(activate("test_interp_bind"), 0);
+
+	EXPECT_EQ(exec_errno(BIND_ARM_PATH), ENOENT);
+}
+
+/* Activating seals it: what can be matched cannot be changed. */
+TEST_F(bound_interp, sealed_once_active)
+{
+	ASSERT_EQ(activate("test_interp_bind"), 0);
+
+	EXPECT_EQ(entry_bind("test_interp_bind", "third", BIND_SECOND), -EBUSY);
+	EXPECT_FALSE(entry_shows("test_interp_bind",
+				 "bpf-interpreter third " BIND_SECOND));
+}
+
+/* The seal is for good: disabling the entry again reopens nothing. */
+TEST_F(bound_interp, disable_does_not_unseal)
+{
+	ASSERT_EQ(activate("test_interp_bind"), 0);
+	ASSERT_EQ(entry_command("test_interp_bind", "0\n"), 0);
+
+	EXPECT_EQ(entry_bind("test_interp_bind", "third", BIND_SECOND), -EBUSY);
+}
+
+/* An entry registered without 'D' is sealed from the start. */
+TEST_F(bound_interp, born_sealed)
+{
+	/* A second entry for the handler the fixture already published. */
+	ASSERT_EQ(register_entry("test_born_sealed", "interp_bind", NULL), 0);
+
+	EXPECT_EQ(entry_bind("test_born_sealed", "first", BIND_FIRST), -EBUSY);
+	unregister("test_born_sealed");
+}
+
+/* A name is bound once; a second use of it is refused. */
+TEST_F(bound_interp, duplicate_name_refused)
+{
+	EXPECT_EQ(entry_bind("test_interp_bind", "first", BIND_SECOND), -EEXIST);
+}
+
+/* A name is a printable word: the entry file reports 'name path' lines. */
+TEST_F(bound_interp, name_must_be_printable)
+{
+	/* A control character would forge a line into the entry file. */
+	EXPECT_EQ(entry_bind("test_interp_bind", "a\tb", BIND_FIRST), -EINVAL);
+	EXPECT_EQ(entry_bind("test_interp_bind", "a\nb", BIND_FIRST), -EINVAL);
+
+	/* A space cannot even be spelled: the path starts after the first one. */
+	EXPECT_EQ(entry_bind("test_interp_bind", "a b", BIND_FIRST), -EINVAL);
+}
+
+/* The command ends at the write: bytes past an embedded nul are refused. */
+TEST_F(bound_interp, trailing_bytes_refused)
+{
+	char cmd[PATH_MAX];
+	size_t len;
+	int fd;
+
+	/* entry_command() cannot spell a nul, so write the buffer raw. */
+	snprintf(cmd, sizeof(cmd), "+nul %s", BIND_FIRST);
+	len = strlen(cmd) + 1;
+	memcpy(cmd + len, "junk", sizeof("junk"));
+	len += sizeof("junk");
+
+	fd = open(BINFMT_DIR "/test_interp_bind", O_WRONLY | O_CLOEXEC);
+	ASSERT_GE(fd, 0);
+	EXPECT_EQ(write(fd, cmd, len), -1);
+	EXPECT_EQ(errno, EINVAL);
+	close(fd);
+
+	EXPECT_FALSE(entry_shows("test_interp_bind",
+				 "bpf-interpreter nul " BIND_FIRST));
+}
+
+/* An entry binds at most BIND_MAX interpreters. */
+TEST_F(bound_interp, capped_bindings)
+{
+	char name[16];
+	int i;
+
+	/* The fixture bound "first" and "second" already. */
+	for (i = 2; i < BIND_MAX; i++) {
+		snprintf(name, sizeof(name), "n%d", i);
+		ASSERT_EQ(entry_bind("test_interp_bind", name, BIND_FIRST), 0);
+	}
+	EXPECT_EQ(entry_bind("test_interp_bind", "over", BIND_FIRST), -ENOSPC);
+}
+
 TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/exec/interp_bind.bpf.c b/tools/testing/selftests/exec/interp_bind.bpf.c
new file mode 100644
index 000000000000..1ce45cca215f
--- /dev/null
+++ b/tools/testing/selftests/exec/interp_bind.bpf.c
@@ -0,0 +1,76 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * binfmt_misc_ops handler for the selftest's bound-interpreter case: one
+ * handler, one entry, an interpreter per guest architecture - each bound to
+ * a file when the entry was registered rather than to a path resolved at
+ * exec time. The load program names the one it wants; a name the entry did
+ * not bind fails the exec, which the harness checks too.
+ */
+#include "vmlinux.h"
+#include <bpf/bpf_helpers.h>
+#include <bpf/bpf_tracing.h>
+
+char _license[] SEC("license") = "GPL";
+
+#define EI_CLASS	4
+#define ELFCLASS64	2
+#define E_MACHINE_OFF	18
+#define EM_ARM		40
+#define EM_AARCH64	183
+#define EM_RISCV	243
+
+extern int bpf_binprm_select_interp(struct linux_binprm *bprm,
+				    const char *name, size_t name__sz) __ksym;
+
+/* The guest architecture of a 64-bit ELF, or zero if it is not one. */
+static __u16 elf_machine(struct linux_binprm *bprm)
+{
+	if (bprm->buf[0] != 0x7f || bprm->buf[1] != 'E' ||
+	    bprm->buf[2] != 'L' || bprm->buf[3] != 'F' ||
+	    bprm->buf[EI_CLASS] != ELFCLASS64)
+		return 0;
+
+	/* Little-endian 16-bit field, read byte-wise for the verifier. */
+	return (__u8)bprm->buf[E_MACHINE_OFF] |
+	       ((__u16)(__u8)bprm->buf[E_MACHINE_OFF + 1] << 8);
+}
+
+SEC("struct_ops.s/match")
+bool BPF_PROG(interp_bind_match, struct linux_binprm *bprm)
+{
+	__u16 machine = elf_machine(bprm);
+
+	return machine == EM_AARCH64 || machine == EM_RISCV ||
+	       machine == EM_ARM;
+}
+
+SEC("struct_ops.s/load")
+int BPF_PROG(interp_bind_load, struct linux_binprm *bprm)
+{
+	/*
+	 * Names, not paths: each one selects a file the entry pre-opened, so
+	 * nothing is resolved here or later, in any namespace. The buffers
+	 * are on the stack because the verifier rejects .rodata for a sized
+	 * memory argument.
+	 */
+	char first[] = "first";
+	char second[] = "second";
+	char unbound[] = "unbound";
+
+	switch (elf_machine(bprm)) {
+	case EM_AARCH64:
+		return bpf_binprm_select_interp(bprm, first, sizeof(first));
+	case EM_RISCV:
+		return bpf_binprm_select_interp(bprm, second, sizeof(second));
+	}
+
+	/* The entry bound nothing under this name: -ENOENT fails the exec. */
+	return bpf_binprm_select_interp(bprm, unbound, sizeof(unbound));
+}
+
+SEC(".struct_ops.link")
+struct binfmt_misc_ops interp_bind = {
+	.match	= (void *)interp_bind_match,
+	.load	= (void *)interp_bind_load,
+	.name	= "interp_bind",
+};

-- 
2.53.0


  parent reply	other threads:[~2026-07-30 13:35 UTC|newest]

Thread overview: 12+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-07-30 13:34 [PATCH 0/9] binfmt_misc: bind interpreters to a bpf-backed entry Christian Brauner
2026-07-30 13:34 ` [PATCH 1/9] binfmt_misc: let a register string create an entry disabled Christian Brauner
2026-07-30 13:47   ` sashiko-bot
2026-07-30 13:34 ` [PATCH 2/9] selftests/exec: let binfmt_flag_supported() return a bool Christian Brauner
2026-07-30 13:34 ` [PATCH 3/9] selftests/exec: test registering an entry disabled Christian Brauner
2026-07-30 13:45   ` sashiko-bot
2026-07-30 13:34 ` [PATCH 4/9] binfmt_misc: document " Christian Brauner
2026-07-30 13:34 ` [PATCH 5/9] selftests/exec: share the bpf handler preconditions Christian Brauner
2026-07-30 13:34 ` [PATCH 6/9] binfmt_misc: carry pre-opened interpreters in struct binfmt_misc_interp Christian Brauner
2026-07-30 13:34 ` [PATCH 7/9] binfmt_misc: let a 'B' entry bind its interpreters Christian Brauner
2026-07-30 13:34 ` Christian Brauner [this message]
2026-07-30 13:34 ` [PATCH 9/9] binfmt_misc: document interpreters bound by a 'B' entry Christian Brauner

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260730-work-binfmt_misc-preopen-v1-8-4a0b0da71f16@kernel.org \
    --to=brauner@kernel.org \
    --cc=ast@kernel.org \
    --cc=bpf@vger.kernel.org \
    --cc=corbet@lwn.net \
    --cc=daniel@iogearbox.net \
    --cc=farid.m.zakaria@gmail.com \
    --cc=jack@suse.cz \
    --cc=jannh@google.com \
    --cc=kees@kernel.org \
    --cc=linux-fsdevel@vger.kernel.org \
    --cc=linux-mm@kvack.org \
    --cc=mail@johnericson.me \
    --cc=viro@zeniv.linux.org.uk \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.