libarchive: Handle UTF-8 filenames without locale dependency
authorColin Walters <walters@verbum.org>
Thu, 8 Jan 2026 21:24:14 +0000 (16:24 -0500)
committerColin Walters <walters@verbum.org>
Wed, 21 Jan 2026 22:15:22 +0000 (17:15 -0500)
When importing archives (including OCI container layers), libarchive
attempts to convert filenames from UTF-8 to the current locale charset.
In POSIX/C locale (which uses ASCII), this conversion fails for any
non-ASCII UTF-8 characters, returning ARCHIVE_WARN.

This is triggered by Python 3.14 which creates a "๐œ‹thon" symlink in
venvs, and affects bootc installations in environments where LANG is
not set (defaulting to POSIX locale).

Fix this by:

1. Using archive_entry_pathname_utf8() and archive_entry_symlink_utf8()
   which return UTF-8 directly without locale conversion

2. Falling back to the regular accessors with explicit UTF-8 validation
   when the _utf8 variants return NULL

3. Accepting ARCHIVE_WARN from archive_read_next_header() since we now
   validate UTF-8 ourselves rather than relying on libarchive charset
   conversion

This matches the behavior of GNU tar which treats filenames as opaque
bytes without charset conversion.

Closes: https://github.com/ostreedev/ostree/issues/3431
src/libostree/ostree-repo-libarchive.c
tests/test-libarchive.sh

index 65a309335f55bbc62fa3d467bf1f991495422eb4..8e050e26a99dac9a80788b8f4e742c6bea156110 100644 (file)
@@ -44,6 +44,74 @@ propagate_libarchive_error (GError **error, struct archive *a)
   g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, "%s", archive_error_string (a));
 }
 
+/*
+ * Get pathname from archive entry as UTF-8.
+ *
+ * libarchive attempts to convert filenames to the current locale's charset,
+ * which fails in POSIX/C locale for non-ASCII UTF-8 characters. This function
+ * uses archive_entry_pathname_utf8() to bypass locale conversion, falling back
+ * to archive_entry_pathname() with explicit UTF-8 validation.
+ *
+ * Returns NULL and sets error if the pathname is missing or not valid UTF-8.
+ */
+static const char *
+archive_entry_require_pathname_utf8 (struct archive_entry *entry, GError **error)
+{
+  /* Try the UTF-8 accessor first - this returns the UTF-8 form directly
+   * without locale conversion. */
+  const char *pathname = archive_entry_pathname_utf8 (entry);
+  if (pathname != NULL)
+    return pathname;
+
+  /* Fall back to regular accessor. When libarchive's charset conversion
+   * fails (e.g., in POSIX locale), it falls back to copying raw bytes,
+   * which for OCI/Docker tarballs should be valid UTF-8. */
+  pathname = archive_entry_pathname (entry);
+  if (pathname == NULL)
+    {
+      g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, "Archive entry has no pathname");
+      return NULL;
+    }
+
+  if (!g_utf8_validate (pathname, -1, NULL))
+    {
+      g_set_error (error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
+                   "Archive entry pathname is not valid UTF-8");
+      return NULL;
+    }
+
+  return pathname;
+}
+
+/*
+ * Get symlink target from archive entry, validating it is UTF-8.
+ * Returns NULL (without error) if entry is not a symlink.
+ * Returns NULL with error set if symlink target is not valid UTF-8.
+ */
+static const char *
+archive_entry_require_symlink_utf8 (struct archive_entry *entry, GError **error)
+{
+  /* Try the UTF-8 accessor first - this returns the UTF-8 form directly
+   * without locale conversion. */
+  const char *target = archive_entry_symlink_utf8 (entry);
+  if (target != NULL)
+    return target;
+
+  /* Fall back to regular accessor with explicit UTF-8 validation */
+  target = archive_entry_symlink (entry);
+  if (target == NULL)
+    return NULL;
+
+  if (!g_utf8_validate (target, -1, NULL))
+    {
+      g_set_error (error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
+                   "Archive entry symlink target is not valid UTF-8");
+      return NULL;
+    }
+
+  return target;
+}
+
 static const char *
 path_relative (const char *src, GError **error)
 {
@@ -131,21 +199,35 @@ read_archive_entry_stat (struct archive_entry *entry, struct stat *stbuf)
     stbuf->st_mode |= S_IFREG;
 }
 
-/* Create a GFileInfo from archive_entry_stat() */
+/* Create a GFileInfo from archive_entry_stat().
+ *
+ * For symlinks, validates that the target is valid UTF-8.
+ * Returns NULL with error set on failure.
+ */
 static GFileInfo *
-file_info_from_archive_entry (struct archive_entry *entry)
+file_info_from_archive_entry (struct archive_entry *entry, GError **error)
 {
   struct stat stbuf;
   read_archive_entry_stat (entry, &stbuf);
 
-  g_autoptr (GFileInfo) info = _ostree_stbuf_to_gfileinfo (&stbuf);
+  /* For symlinks, validate and get the UTF-8 target */
+  const char *symlink_target = NULL;
   if (S_ISLNK (stbuf.st_mode))
     {
-      const char *target = archive_entry_symlink (entry);
-      if (target != NULL)
-        g_file_info_set_attribute_byte_string (info, "standard::symlink-target", target);
+      symlink_target = archive_entry_require_symlink_utf8 (entry, error);
+      /* A symlink without a target is an error */
+      if (symlink_target == NULL)
+        {
+          if (error != NULL && *error == NULL)
+            g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, "Symlink entry has no target");
+          return NULL;
+        }
     }
 
+  g_autoptr (GFileInfo) info = _ostree_stbuf_to_gfileinfo (&stbuf);
+  if (symlink_target != NULL)
+    g_file_info_set_attribute_byte_string (info, "standard::symlink-target", symlink_target);
+
   return g_steal_pointer (&info);
 }
 
@@ -222,6 +304,7 @@ typedef struct
   OstreeMutableTree *root;
   struct archive *archive;
   struct archive_entry *entry;
+  GFileInfo *file_info; /* Cached file info for current entry, set by aic_import_entry */
   GHashTable *deferred_hardlinks;
   OstreeRepoCommitModifier *modifier;
 } OstreeRepoArchiveImportContext;
@@ -255,7 +338,10 @@ aic_get_final_path (OstreeRepoArchiveImportContext *ctx, const char *path, GErro
 static inline char *
 aic_get_final_entry_pathname (OstreeRepoArchiveImportContext *ctx, GError **error)
 {
-  const char *pathname = archive_entry_pathname (ctx->entry);
+  const char *pathname = archive_entry_require_pathname_utf8 (ctx->entry, error);
+  if (pathname == NULL)
+    return NULL;
+
   g_autofree char *final = aic_get_final_path (ctx, pathname, error);
   if (final == NULL)
     return NULL;
@@ -288,12 +374,11 @@ static OstreeRepoCommitFilterResult
 aic_apply_modifier_filter (OstreeRepoArchiveImportContext *ctx, const char *relpath,
                            GFileInfo **out_file_info)
 {
-  g_autoptr (GFileInfo) file_info = NULL;
   g_autofree char *abspath = NULL;
   const char *cb_path = NULL;
 
   if (ctx->opts->callback_with_entry_pathname)
-    cb_path = archive_entry_pathname (ctx->entry);
+    cb_path = archive_entry_pathname_utf8 (ctx->entry);
   else
     {
       /* the user expects an abspath (where the dir to commit represents /) */
@@ -301,9 +386,11 @@ aic_apply_modifier_filter (OstreeRepoArchiveImportContext *ctx, const char *relp
       cb_path = abspath;
     }
 
-  file_info = file_info_from_archive_entry (ctx->entry);
+  /* Use the pre-validated file_info from ctx, computed by aic_import_entry
+   * which has proper error propagation for UTF-8 validation failures. */
+  g_assert (ctx->file_info != NULL);
 
-  return _ostree_repo_commit_modifier_apply (ctx->repo, ctx->modifier, cb_path, file_info,
+  return _ostree_repo_commit_modifier_apply (ctx->repo, ctx->modifier, cb_path, ctx->file_info,
                                              out_file_info);
 }
 
@@ -444,7 +531,7 @@ aic_get_xattrs (OstreeRepoArchiveImportContext *ctx, const char *path, GFileInfo
     }
 
   if (ctx->opts->callback_with_entry_pathname)
-    cb_path = archive_entry_pathname (ctx->entry);
+    cb_path = archive_entry_pathname_utf8 (ctx->entry);
 
   if (ctx->modifier && ctx->modifier->xattr_callback)
     {
@@ -625,8 +712,21 @@ aic_import_entry (OstreeRepoArchiveImportContext *ctx, GCancellable *cancellable
   if (path == NULL)
     return FALSE;
 
+  /* Compute file info early while we have error propagation. This validates
+   * symlink targets are UTF-8 and caches the result in ctx->file_info for
+   * use by aic_apply_modifier_filter which cannot propagate errors.
+   * The struct owns the reference until we clear it. */
+  g_assert (ctx->file_info == NULL);
+  ctx->file_info = file_info_from_archive_entry (ctx->entry, error);
+  if (ctx->file_info == NULL)
+    return FALSE;
+
   g_autoptr (GFileInfo) fi = NULL;
-  if (aic_apply_modifier_filter (ctx, path, &fi) == OSTREE_REPO_COMMIT_FILTER_SKIP)
+  OstreeRepoCommitFilterResult filter_result = aic_apply_modifier_filter (ctx, path, &fi);
+
+  g_clear_object (&ctx->file_info);
+
+  if (filter_result == OSTREE_REPO_COMMIT_FILTER_SKIP)
     return TRUE;
 
   g_autoptr (OstreeMutableTree) parent = NULL;
@@ -799,11 +899,17 @@ ostree_repo_import_archive_to_mtree (OstreeRepo *self, OstreeRepoImportArchiveOp
       int r = archive_read_next_header (a, &aictx.entry);
       if (r == ARCHIVE_EOF)
         break;
-      if (r != ARCHIVE_OK)
+      /* Accept ARCHIVE_WARN: libarchive returns this for "partial success"
+       * conditions like charset conversion failures (e.g., UTF-8 to ASCII
+       * in POSIX locale). The entry is still fully populated; we validate
+       * filenames as UTF-8 ourselves. Fail only on ARCHIVE_FATAL/FAILED. */
+      if (r != ARCHIVE_OK && r != ARCHIVE_WARN)
         {
           propagate_libarchive_error (error, a);
           goto out;
         }
+      if (r == ARCHIVE_WARN)
+        g_debug ("libarchive warning: %s", archive_error_string (a));
 
       if (g_cancellable_set_error_if_cancelled (cancellable, error))
         goto out;
index d9ce37b29dee64b5657c513bb2e6a341fff74801..09b46514c8e6b597620bb026561ee3303086f3d2 100755 (executable)
@@ -23,7 +23,7 @@ set -euo pipefail
 
 skip_without_ostree_feature libarchive
 
-echo "1..18"
+echo "1..19"
 
 setup_test_repository "bare"
 
@@ -243,3 +243,36 @@ assert_file_has_content sizes.txt 'Compressed size (needed/total): 0[ ย ]bytes/1
 assert_file_has_content sizes.txt 'Unpacked size (needed/total): 0[ ย ]bytes/921[ ย ]bytes'
 assert_file_has_content sizes.txt 'Number of objects (needed/total): 0/14'
 echo "ok tar sizes metadata"
+
+# Test UTF-8 filenames work in POSIX/C locale (where libarchive's charset
+# conversion fails). This reproduces the issue from
+# https://github.com/ostreedev/ostree/issues/3431 where Python 3.14's
+# venv creates a symlink named "๐œ‹thon" (U+1D70B, Mathematical Italic Small Pi).
+cd ${test_tmpdir}
+rm -rf utf8-test
+mkdir utf8-test
+cd utf8-test
+mkdir -p usr/bin
+echo "#!/bin/sh" > usr/bin/python3
+chmod +x usr/bin/python3
+# Create symlink with non-ASCII UTF-8 name (๐œ‹ = 4-byte UTF-8: F0 9D 9C 8B)
+# and symlink target with non-ASCII UTF-8
+ln -s python3 'usr/bin/๐œ‹thon'
+ln -s '๐œ‹thon' 'usr/bin/๐œ‹-link'
+tar -c -f ../utf8.tar .
+cd ..
+
+# Import with POSIX locale - this previously failed with:
+# "Pathname can't be converted from UTF-8 to current locale"
+LC_ALL=C $OSTREE commit -s "from tar with utf8" -b test-tar-utf8 \
+  --tar-autocreate-parents \
+  --tree=tar=utf8.tar
+# Verify the files exist with correct names
+$OSTREE ls test-tar-utf8 /usr/bin/๐œ‹thon >/dev/null
+$OSTREE ls test-tar-utf8 /usr/bin/๐œ‹-link >/dev/null
+# Verify symlink targets are correct
+rm -rf utf8-checkout
+$OSTREE checkout test-tar-utf8 utf8-checkout
+test "$(readlink utf8-checkout/usr/bin/๐œ‹thon)" = "python3"
+test "$(readlink utf8-checkout/usr/bin/๐œ‹-link)" = "๐œ‹thon"
+echo "ok tar commit with utf8 filenames in POSIX locale"