From 23a526546db971a1dcd73fa29bbe2499aa6bb2f4 Mon Sep 17 00:00:00 2001
From: Simon McVittie <smcv@collabora.com>
Date: Tue, 12 Nov 2024 21:30:20 +0000
Subject: [PATCH] pressure-vessel: Generate /etc/passwd, /etc/group for the
 container

Sometimes our current uid will not be present in /etc/passwd.
For example, when using systemd-homed the equivalent of a passwd(5)
record is synthesized by libnss_systemd. A similar thing could happen
when using a remote user directory like LDAP, or for the group database.

Inside the container, we have the basic nsswitch plugins from glibc
(notably libnss_files, which reads /etc/passwd and /etc/group), but
we do not have access to non-standard nsswitch plugins like
libnss_systemd or LDAP. This can cause crashes or bugs in games that
assume that `getpwuid(getuid())` will always succeed (like Factorio),
especially if they do not respect `$HOME` (like Factorio).

Instead of bind-mounting the host system's /etc/passwd and /etc/group
as-is, we can synthesize a /etc/passwd and /etc/group that are
guaranteed to contain at least our uid and primary gid, matching what
Flatpak does.

Flatpak generates very minimal files that list our uid and gid, plus
the default overflow uid and gid 65534 (labelled as `nfsnobody` for
historical reasons). However, pressure-vessel has historically provided
more complete files, so it's conceivable that a game might be relying
on being able to resolve (for example) root as uid 0; so we copy
everything from the host /etc/passwd and /etc/group, excluding only
the line(s) that would duplicate the one we synthesize.

Resolves: https://github.com/ValveSoftware/steam-runtime/issues/705
Signed-off-by: Simon McVittie <smcv@collabora.com>
---
 pressure-vessel/meson.build |   2 +
 pressure-vessel/passwd.c    | 263 ++++++++++++++++++++++++++++++++++++
 pressure-vessel/passwd.h    |  28 ++++
 pressure-vessel/runtime.c   |  31 ++++-
 4 files changed, 320 insertions(+), 4 deletions(-)
 create mode 100644 pressure-vessel/passwd.c
 create mode 100644 pressure-vessel/passwd.h

diff --git a/pressure-vessel/meson.build b/pressure-vessel/meson.build
index 245e313c7..5bb38e9cf 100644
--- a/pressure-vessel/meson.build
+++ b/pressure-vessel/meson.build
@@ -171,6 +171,8 @@ pressure_vessel_wrap_lib = static_library(
     'flatpak-run-x11.c',
     'graphics-provider.c',
     'graphics-provider.h',
+    'passwd.c',
+    'passwd.h',
     'runtime.c',
     'runtime.h',
     'wrap-context.c',
diff --git a/pressure-vessel/passwd.c b/pressure-vessel/passwd.c
new file mode 100644
index 000000000..f17e48e80
--- /dev/null
+++ b/pressure-vessel/passwd.c
@@ -0,0 +1,263 @@
+/*
+ * Copyright © 2020-2024 Collabora Ltd.
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ */
+
+#include "passwd.h"
+
+#include "steam-runtime-tools/log-internal.h"
+
+/* Append @field to @buffer, replacing colons or newlines with '_'
+ * to avoid possibly corrupting the passwd(5)/group(5) syntax. */
+static void
+append_field (GString *buffer,
+              const char *field)
+{
+  gsize len = strlen (field);
+  gsize old_len = buffer->len;
+  gboolean warned = FALSE;
+  gsize i;
+
+  g_string_append (buffer, field);
+
+  for (i = 0; i < len; i++)
+    {
+      switch (buffer->str[old_len + i])
+        {
+          case ':':
+          case '\n':
+            if (!warned)
+              {
+                _srt_log_warning ("Field \"%s\" cannot be represented in passwd(5)/group(5)",
+                                  field);
+                warned = TRUE;
+              }
+
+            buffer->str[old_len + i] = '_';
+            break;
+
+          default:
+            break;
+        }
+    }
+
+  g_assert (buffer->str[old_len + len] == '\0');
+}
+
+/*
+ * Assume that the first line in @buffer is a single user in passwd(5)
+ * syntax or a single group in group(5) syntax.
+ *
+ * Append all lines from @source/@path to @buffer, unless they would
+ * duplicate the user/group that is already there.
+ */
+static gboolean
+append_remaining_lines (GString *buffer,
+                        SrtSysroot *source,
+                        const char *path,
+                        GError **error)
+{
+  g_autofree gchar *exclude_same_name = NULL;
+  g_autofree gchar *content = NULL;
+  const char *line;
+  gsize line_num;
+
+  if (buffer->len != 0)
+    {
+      const char *colon;
+
+      colon = strchr (buffer->str, ':');
+      g_assert (colon != NULL);
+      /* username + ':' as a prefix to exclude matching lines, e.g. "gfreeman:" */
+      exclude_same_name = g_strndup (buffer->str, (colon - buffer->str) + 1);
+    }
+
+  if (!_srt_sysroot_load (source, path, SRT_RESOLVE_FLAGS_READABLE,
+                          NULL, &content, NULL, error))
+    return FALSE;
+
+  line = content;
+  line_num = 0;
+
+  while (TRUE)
+    {
+      const char *end_of_line;
+      gsize len;
+
+      end_of_line = strchr (line, '\n');
+
+      if (end_of_line == NULL)
+        len = strlen (line);
+      else
+        len = end_of_line - line;
+
+      if (exclude_same_name != NULL && g_str_has_prefix (line, exclude_same_name))
+        {
+          g_debug ("Skipping %s:%zu \"%s...\" because it is our user/group",
+                   path, line_num, exclude_same_name);
+        }
+      else if (len > 0)
+        {
+          g_string_append_len (buffer, line, len);
+          g_string_append_c (buffer, '\n');
+        }
+
+      if (end_of_line == NULL)
+        break;
+
+      line = end_of_line + 1;
+      line_num++;
+    }
+
+  return TRUE;
+}
+
+/* getpwuid(), but with mock output for unit-testing */
+static const struct passwd *
+getpwuid_wrapper (uid_t uid,
+                  PvMockPasswdLookup *mock)
+{
+  if (G_LIKELY (mock == NULL))
+    return getpwuid (uid);
+
+  g_assert_cmpint (mock->uid, ==, uid);
+
+  if (mock->lookup_errno != 0)
+    errno = mock->lookup_errno;
+
+  return mock->pwd;
+}
+
+/*
+ * @source: A sysroot from which we can read /etc/passwd
+ * @mock: (allow-none): A mock version of the real getpwuid() result,
+ *  used during unit-testing
+ *
+ * Return contents for a passwd(5) that has at least our own uid.
+ */
+gchar *
+pv_generate_etc_passwd (SrtSysroot *source,
+                        PvMockPasswdLookup *mock)
+{
+  g_autoptr(GError) local_error = NULL;
+  g_autoptr(GString) buffer = NULL;
+  struct passwd fallback =
+    {
+      .pw_name = NULL,
+      .pw_passwd = (char *) "x",
+      .pw_uid = -1,
+      .pw_gid = -1,
+      .pw_gecos = NULL,
+      .pw_dir = NULL,
+      .pw_shell = (char *) "/bin/bash",
+    };
+  const struct passwd *pw = NULL;
+
+  fallback.pw_name = (char *) g_get_user_name ();
+
+  if (fallback.pw_name == NULL)
+    fallback.pw_name = (char *) "user";
+
+  fallback.pw_gecos = (char *) g_get_real_name ();
+
+  if (fallback.pw_gecos == NULL)
+    fallback.pw_gecos = fallback.pw_name;
+
+  fallback.pw_uid = getuid ();
+  fallback.pw_gid = getgid ();
+  fallback.pw_dir = (char *) g_get_home_dir ();
+
+  errno = 0;
+  pw = getpwuid_wrapper (fallback.pw_uid, mock);
+
+  if (pw == NULL)
+    {
+      int saved_errno = errno;
+
+      _srt_log_warning ("Unable to resolve uid %d: %s",
+                        fallback.pw_uid,
+                        saved_errno == 0 ? "user not found" : g_strerror (errno));
+      pw = &fallback;
+    }
+
+  g_assert (pw != NULL);
+  buffer = g_string_new ("");
+  append_field (buffer, pw->pw_name);
+  g_string_append_printf (buffer, ":x:%d:%d:", pw->pw_uid, pw->pw_gid);
+  append_field (buffer, pw->pw_gecos);
+  g_string_append_c (buffer, ':');
+  append_field (buffer, pw->pw_dir);
+  /* We always behave as if the user's shell is bash, because we can rely
+   * on that existing in the container, whereas an alternative shell like
+   * /bin/zsh might not. */
+  g_string_append (buffer, ":/bin/bash\n");
+
+  if (!append_remaining_lines (buffer, source, "/etc/passwd", &local_error))
+    {
+      _srt_log_warning ("%s", local_error->message);
+      g_clear_error (&local_error);
+    }
+
+  return g_string_free (g_steal_pointer (&buffer), FALSE);
+}
+
+/* getgrgid(), but with mock output for unit-testing */
+static const struct group *
+getgrgid_wrapper (gid_t gid,
+                  PvMockPasswdLookup *mock)
+{
+  if (G_LIKELY (mock == NULL))
+    return getgrgid (gid);
+
+  g_assert_cmpint (mock->gid, ==, gid);
+
+  if (mock->lookup_errno != 0)
+    errno = mock->lookup_errno;
+
+  return mock->grp;
+}
+
+/*
+ * @source: A sysroot from which we can read /etc/passwd
+ * @mock: (allow-none): A mock version of the real getgrgid() result,
+ *  used during unit-testing
+ *
+ * Return contents for a group(5) that has at least our own primary gid.
+ */
+gchar *
+pv_generate_etc_group (SrtSysroot *source,
+                       PvMockPasswdLookup *mock)
+{
+  g_autoptr(GError) local_error = NULL;
+  g_autoptr(GString) buffer = NULL;
+  gid_t primary_gid = getgid ();
+  const struct group *gr = NULL;
+
+  errno = 0;
+  gr = getgrgid_wrapper (primary_gid, mock);
+
+  if (gr == NULL)
+    {
+      int saved_errno = errno;
+
+      _srt_log_warning ("Unable to resolve gid %d: %s",
+                        primary_gid,
+                        saved_errno == 0 ? "group not found" : g_strerror (errno));
+    }
+
+  buffer = g_string_new ("");
+
+  if (gr != NULL)
+    {
+      append_field (buffer, gr->gr_name);
+      g_string_append_printf (buffer, ":x:%d:\n", gr->gr_gid);
+    }
+
+  if (!append_remaining_lines (buffer, source, "/etc/group", &local_error))
+    {
+      _srt_log_warning ("%s", local_error->message);
+      g_clear_error (&local_error);
+    }
+
+  return g_string_free (g_steal_pointer (&buffer), FALSE);
+}
diff --git a/pressure-vessel/passwd.h b/pressure-vessel/passwd.h
new file mode 100644
index 000000000..d54b66e41
--- /dev/null
+++ b/pressure-vessel/passwd.h
@@ -0,0 +1,28 @@
+/*
+ * Copyright © 2020-2024 Collabora Ltd.
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ */
+
+#pragma once
+
+#include <sys/types.h>
+#include <grp.h>
+#include <pwd.h>
+
+#include "steam-runtime-tools/glib-backports-internal.h"
+#include "steam-runtime-tools/resolve-in-sysroot-internal.h"
+
+/* Mock data to be used during unit-testing */
+typedef struct
+{
+  uid_t uid;
+  gid_t gid;
+  const struct passwd *pwd;
+  const struct group *grp;
+  int lookup_errno;
+} PvMockPasswdLookup;
+
+gchar *pv_generate_etc_passwd (SrtSysroot *source,
+                               PvMockPasswdLookup *mock);
+gchar *pv_generate_etc_group (SrtSysroot *source,
+                              PvMockPasswdLookup *mock);
diff --git a/pressure-vessel/runtime.c b/pressure-vessel/runtime.c
index ac9df5997..ef0f58819 100644
--- a/pressure-vessel/runtime.c
+++ b/pressure-vessel/runtime.c
@@ -46,6 +46,7 @@
 #include "exports.h"
 #include "flatpak-run-private.h"
 #include "mtree.h"
+#include "passwd.h"
 #include "supported-architectures.h"
 #include "tree-copy.h"
 #include "utils.h"
@@ -3209,10 +3210,6 @@ bind_runtime_base (PvRuntime *self,
   };
   static const char * const from_host[] =
   {
-    /* TODO: Synthesize a passwd with only the user and nobody,
-     * like Flatpak does? */
-    "/etc/group",
-    "/etc/passwd",
     "/etc/host.conf",
     "/etc/hosts",
     "/etc/resolv.conf",
@@ -3496,6 +3493,32 @@ bind_runtime_base (PvRuntime *self,
         g_return_val_if_reached (FALSE);
     }
 
+    {
+      g_autofree gchar *content = pv_generate_etc_passwd (self->real_root, NULL);
+
+      g_assert (content != NULL);
+
+      if (!pv_runtime_bind_into_container (self, bwrap,
+                                           "etc-passwd", content, -1,
+                                           "/etc/passwd",
+                                           PV_RUNTIME_EMULATION_ROOTS_BOTH,
+                                           error))
+        return FALSE;
+    }
+
+    {
+      g_autofree gchar *content = pv_generate_etc_group (self->real_root, NULL);
+
+      g_assert (content != NULL);
+
+      if (!pv_runtime_bind_into_container (self, bwrap,
+                                           "etc-group", content, -1,
+                                           "/etc/group",
+                                           PV_RUNTIME_EMULATION_ROOTS_BOTH,
+                                           error))
+        return FALSE;
+    }
+
   if (self->provider != NULL)
     {
       for (i = 0; from_provider[i] != NULL; i++)
-- 
GitLab