diff mbox series

[pseudo,2/5] tests: Add mv then hardlink testing

Message ID 1778624443-20857-3-git-send-email-mark.hatle@kernel.crashing.org
State New
Headers show
Series Fix rename/renameat w/ hardlinks | expand

Commit Message

Mark Hatle May 12, 2026, 10:20 p.m. UTC
From: Mark Hatle <mark.hatle@amd.com>

Per the discussion:
   https://lists.openembedded.org/g/openembedded-core/topic/119214074#msg236712

Changqing Li found an issue with renameat (mv) from an ignored to an
included path was not updating the database properly.  This could lead
to an abort, but would definitely cause the new file/symlink to have
the wrong (in pseudo terms) uid/gid.

The test-mv-hardlink.sh is an attempt to implement the test case
suggested by Paul Barker.  It was noted that both rename and renameat
may suffer from the same issue, so additional test cases for each
implementation was added as well.

AI-Generated: Implemented with the assistance of github CoPilot (Claude Opus 4.6)

Signed-off-by: Mark Hatle <mark.hatle@amd.com>
---
 test/test-mv-hardlink.sh       |  52 +++++++++++++++++++++
 test/test-rename-hardlink.c    |  87 +++++++++++++++++++++++++++++++++++
 test/test-rename-hardlink.sh   |  14 ++++++
 test/test-renameat-hardlink.c  | 101 +++++++++++++++++++++++++++++++++++++++++
 test/test-renameat-hardlink.sh |  14 ++++++
 5 files changed, 268 insertions(+)
 create mode 100755 test/test-mv-hardlink.sh
 create mode 100644 test/test-rename-hardlink.c
 create mode 100755 test/test-rename-hardlink.sh
 create mode 100644 test/test-renameat-hardlink.c
 create mode 100755 test/test-renameat-hardlink.sh
diff mbox series

Patch

diff --git a/test/test-mv-hardlink.sh b/test/test-mv-hardlink.sh
new file mode 100755
index 0000000..2963ef3
--- /dev/null
+++ b/test/test-mv-hardlink.sh
@@ -0,0 +1,52 @@ 
+#!/bin/bash
+#
+# SPDX-License-Identifier: LGPL-2.1-only
+#
+# Test that rename (mv) from outside PSEUDO_INCLUDE_PATHS followed by
+# hardlink properly tracks file ownership.
+#
+# Reproduces: https://lists.openembedded.org/g/openembedded-core/message/236712
+#
+# In Yocto, files are often moved from ${B} (build dir, outside
+# PSEUDO_INCLUDE_PATHS) to ${D} (image dir, tracked by pseudo) and
+# then hardlinked. If pseudo doesn't track the rename, the hardlink
+# gets recorded with the real UID, causing inconsistent ownership
+# and pseudo abort on subsequent stat.
+
+# Create two directories:
+#   srcdir  - simulates ${B}, outside PSEUDO_INCLUDE_PATHS
+#   destdir - simulates ${D}, inside PSEUDO_INCLUDE_PATHS
+# Use realpath to resolve symlinks, since pseudo canonicalizes paths
+# internally and the PSEUDO_INCLUDE_PATHS prefix must match.
+srcdir=$(mktemp -d "$(realpath "${PWD}")/mv_hl_src_XXXXXX")
+destdir=$(mktemp -d "$(realpath "${PWD}")/mv_hl_dest_XXXXXX")
+trap "rm -rf '$srcdir' '$destdir'" EXIT
+
+# Restrict pseudo tracking to only destdir
+export PSEUDO_INCLUDE_PATHS="$destdir"
+
+echo hello > ${srcdir}/hello.txt
+
+mv ${srcdir}/hello.txt ${destdir}/hello.txt
+ln ${destdir}/hello.txt ${destdir}/hello2.txt
+
+# Both files should report uid 0 under pseudo
+dest_uid=$(\ls -n1 ${destdir}/hello.txt | awk '{ print $3 }')
+link_uid=$(\ls -n1 ${destdir}/hello2.txt | awk '{ print $3 }')
+
+if [ "$dest_uid" != "0" ]; then
+    echo "FAIL: dest uid is $dest_uid, expected 0"
+    exit 1
+fi
+
+if [ "$link_uid" != "0" ]; then
+    echo "FAIL: link uid is $link_uid, expected 0"
+    exit 1
+fi
+
+if [ "$dest_uid" != "$link_uid" ]; then
+    echo "FAIL: UIDs don't match (dest=$dest_uid, link=$link_uid)"
+    exit 1
+fi
+
+exit 0
diff --git a/test/test-rename-hardlink.c b/test/test-rename-hardlink.c
new file mode 100644
index 0000000..d3f7384
--- /dev/null
+++ b/test/test-rename-hardlink.c
@@ -0,0 +1,87 @@ 
+/*
+ * Test that rename() from outside PSEUDO_INCLUDE_PATHS followed by
+ * hardlink properly tracks file ownership.
+ *
+ * SPDX-License-Identifier: LGPL-2.1-only
+ */
+#define _GNU_SOURCE
+
+#include <sys/types.h>
+#include <sys/stat.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <limits.h>
+
+static int failures = 0;
+
+static void check(const char *desc, int condition) {
+	if (!condition) {
+		fprintf(stderr, "FAIL: %s\n", desc);
+		failures++;
+	}
+}
+
+int main(int argc, char *argv[])
+{
+	struct stat st1, st2;
+	char src_path[PATH_MAX];
+	char dest_path[PATH_MAX];
+	char link_path[PATH_MAX];
+	int fd;
+
+	if (argc != 3) {
+		fprintf(stderr, "Usage: %s <src_dir> <dest_dir>\n", argv[0]);
+		return 1;
+	}
+
+	/* Create a file in src_dir (outside PSEUDO_INCLUDE_PATHS) */
+	snprintf(src_path, sizeof(src_path), "%s/testfile.txt", argv[1]);
+	fd = open(src_path, O_CREAT | O_WRONLY, 0644);
+	if (fd < 0) {
+		perror("create source file");
+		return 1;
+	}
+	if (write(fd, "hello\n", 6) != 6) {
+		perror("write");
+		close(fd);
+		return 1;
+	}
+	close(fd);
+
+	/* rename() from untracked src_dir to tracked dest_dir */
+	snprintf(dest_path, sizeof(dest_path), "%s/testfile.txt", argv[2]);
+	if (rename(src_path, dest_path) != 0) {
+		perror("rename");
+		return 1;
+	}
+
+	/* Create a hardlink in the tracked directory */
+	snprintf(link_path, sizeof(link_path), "%s/testfile2.txt", argv[2]);
+	if (link(dest_path, link_path) != 0) {
+		perror("link");
+		return 1;
+	}
+
+	/* Stat both files and verify consistent uid 0 */
+	if (stat(dest_path, &st1) != 0) {
+		perror("stat dest");
+		return 1;
+	}
+	if (stat(link_path, &st2) != 0) {
+		perror("stat link");
+		return 1;
+	}
+
+	check("same inode", st1.st_ino == st2.st_ino);
+	check("UIDs match", st1.st_uid == st2.st_uid);
+	check("dest uid is 0", st1.st_uid == 0);
+	check("link uid is 0", st2.st_uid == 0);
+
+	unlink(link_path);
+	unlink(dest_path);
+
+	return failures;
+}
diff --git a/test/test-rename-hardlink.sh b/test/test-rename-hardlink.sh
new file mode 100755
index 0000000..8f2be60
--- /dev/null
+++ b/test/test-rename-hardlink.sh
@@ -0,0 +1,14 @@ 
+#!/bin/bash
+#
+# SPDX-License-Identifier: LGPL-2.1-only
+#
+# Test that rename() from outside PSEUDO_INCLUDE_PATHS followed by
+# hardlink properly tracks file ownership.
+
+srcdir=$(mktemp -d "$(realpath "${PWD}")/ren_hl_src_XXXXXX")
+destdir=$(mktemp -d "$(realpath "${PWD}")/ren_hl_dest_XXXXXX")
+trap "rm -rf '$srcdir' '$destdir'" EXIT
+
+export PSEUDO_INCLUDE_PATHS="$destdir"
+
+./test/test-rename-hardlink "$srcdir" "$destdir"
diff --git a/test/test-renameat-hardlink.c b/test/test-renameat-hardlink.c
new file mode 100644
index 0000000..9c4840c
--- /dev/null
+++ b/test/test-renameat-hardlink.c
@@ -0,0 +1,101 @@ 
+/*
+ * Test that renameat() from outside PSEUDO_INCLUDE_PATHS followed by
+ * hardlink properly tracks file ownership.
+ *
+ * SPDX-License-Identifier: LGPL-2.1-only
+ */
+#define _GNU_SOURCE
+
+#include <sys/types.h>
+#include <sys/stat.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <limits.h>
+
+static int failures = 0;
+
+static void check(const char *desc, int condition) {
+	if (!condition) {
+		fprintf(stderr, "FAIL: %s\n", desc);
+		failures++;
+	}
+}
+
+int main(int argc, char *argv[])
+{
+	struct stat st1, st2;
+	char dest_path[PATH_MAX];
+	char link_path[PATH_MAX];
+	int fd, olddirfd, newdirfd;
+
+	if (argc != 3) {
+		fprintf(stderr, "Usage: %s <src_dir> <dest_dir>\n", argv[0]);
+		return 1;
+	}
+
+	/* Open directory fds for renameat */
+	olddirfd = open(argv[1], O_RDONLY | O_DIRECTORY);
+	if (olddirfd < 0) {
+		perror("open src_dir");
+		return 1;
+	}
+	newdirfd = open(argv[2], O_RDONLY | O_DIRECTORY);
+	if (newdirfd < 0) {
+		perror("open dest_dir");
+		close(olddirfd);
+		return 1;
+	}
+
+	/* Create a file in src_dir (outside PSEUDO_INCLUDE_PATHS) */
+	fd = openat(olddirfd, "testfile.txt", O_CREAT | O_WRONLY, 0644);
+	if (fd < 0) {
+		perror("create source file");
+		return 1;
+	}
+	if (write(fd, "hello\n", 6) != 6) {
+		perror("write");
+		close(fd);
+		return 1;
+	}
+	close(fd);
+
+	/* renameat() from untracked src_dir to tracked dest_dir */
+	if (renameat(olddirfd, "testfile.txt", newdirfd, "testfile.txt") != 0) {
+		perror("renameat");
+		return 1;
+	}
+
+	/* Create a hardlink using linkat in the tracked directory */
+	if (linkat(newdirfd, "testfile.txt", newdirfd, "testfile2.txt", 0) != 0) {
+		perror("linkat");
+		return 1;
+	}
+
+	/* Stat both files and verify consistent uid 0 */
+	snprintf(dest_path, sizeof(dest_path), "%s/testfile.txt", argv[2]);
+	snprintf(link_path, sizeof(link_path), "%s/testfile2.txt", argv[2]);
+
+	if (stat(dest_path, &st1) != 0) {
+		perror("stat dest");
+		return 1;
+	}
+	if (stat(link_path, &st2) != 0) {
+		perror("stat link");
+		return 1;
+	}
+
+	check("same inode", st1.st_ino == st2.st_ino);
+	check("UIDs match", st1.st_uid == st2.st_uid);
+	check("dest uid is 0", st1.st_uid == 0);
+	check("link uid is 0", st2.st_uid == 0);
+
+	unlinkat(newdirfd, "testfile2.txt", 0);
+	unlinkat(newdirfd, "testfile.txt", 0);
+	close(olddirfd);
+	close(newdirfd);
+
+	return failures;
+}
diff --git a/test/test-renameat-hardlink.sh b/test/test-renameat-hardlink.sh
new file mode 100755
index 0000000..b7e3a9e
--- /dev/null
+++ b/test/test-renameat-hardlink.sh
@@ -0,0 +1,14 @@ 
+#!/bin/bash
+#
+# SPDX-License-Identifier: LGPL-2.1-only
+#
+# Test that renameat() from outside PSEUDO_INCLUDE_PATHS followed by
+# hardlink properly tracks file ownership.
+
+srcdir=$(mktemp -d "$(realpath "${PWD}")/renat_hl_src_XXXXXX")
+destdir=$(mktemp -d "$(realpath "${PWD}")/renat_hl_dest_XXXXXX")
+trap "rm -rf '$srcdir' '$destdir'" EXIT
+
+export PSEUDO_INCLUDE_PATHS="$destdir"
+
+./test/test-renameat-hardlink "$srcdir" "$destdir"