From a396e7db2b265f7b97459e2a37825a70c40540ee Mon Sep 17 00:00:00 2001 From: "William D. Jones" Date: Tue, 30 Sep 2025 12:56:15 -0400 Subject: [PATCH 1/2] Add tests for diff output of archives with hard links. --- src/borg/testsuite/archiver/diff_cmd_test.py | 81 ++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/borg/testsuite/archiver/diff_cmd_test.py b/src/borg/testsuite/archiver/diff_cmd_test.py index 8d5735154..6cf53b3c0 100644 --- a/src/borg/testsuite/archiver/diff_cmd_test.py +++ b/src/borg/testsuite/archiver/diff_cmd_test.py @@ -3,6 +3,7 @@ import os from pathlib import Path import stat import time +import pytest from ...constants import * # NOQA from .. import are_symlinks_supported, are_hardlinks_supported @@ -318,3 +319,83 @@ def test_sort_option(archivers, request): outputs = output.splitlines() assert len(outputs) == len(expected) assert all(x in line for x, line in zip(expected, outputs)) + + +@pytest.mark.skipif(not are_hardlinks_supported(), reason="hardlinks not supported") +def test_hard_link_deletion_and_replacement(archivers, request): + archiver = request.getfixturevalue(archivers) + + path_a = os.path.join(archiver.input_path, "a") + path_b = os.path.join(archiver.input_path, "b") + os.mkdir(path_a) + os.mkdir(path_b) + hl_a = os.path.join(path_a, "hardlink") + hl_b = os.path.join(path_b, "hardlink") + create_regular_file(archiver.input_path, hl_a, contents=b"123456") + os.link(hl_a, hl_b) + + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "test0", "input") + os.unlink(hl_a) # Don't duplicate warning message- one is enough. + cmd(archiver, "create", "test1", "input") + + # Moral equivalent of test_multiple_link_exclusion in borg v1.x... see #8344 + # Borg v2 doesn't have this issue comparing hard-links, so we'll defer to + # POSIX behavior: + # https://pubs.opengroup.org/onlinepubs/9799919799/functions/unlink.html + # Upon successful completion, unlink() shall mark for update the last data modification + # and last file status change timestamps of the parent directory. Also, if the + # file's link count is not 0, the last file status change timestamp of the + # file shall be marked for update. + output = cmd( + archiver, "diff", "--pattern=+ fm:input/b", "--pattern=! **/", "test0", "test1", exit_code=EXIT_SUCCESS + ) + lines = output.splitlines() + # Directory was excluded. + assert_line_not_exists(lines, "input/a$") + # Remaining hardlink + assert_line_exists(lines, "ctime:.*input/b/hardlink") + assert_line_not_exists(lines, ".*mtime:.*input/b/hardlink") + # Deleted hardlink was excluded + assert_line_not_exists(lines, "input/a/hardlink$") + + # Now try again, except with no patterns! + output = cmd(archiver, "diff", "test0", "test1", exit_code=EXIT_SUCCESS) + lines = output.splitlines() + # Directory... preferably, let's not care about order differences are presented. + assert_line_exists(lines, "[cm]time:.*[cm]time:.*input/a") + # Remaining hardlink + assert_line_exists(lines, "ctime:.*input/b/hardlink") + assert_line_not_exists(lines, ".*mtime:.*input/b/hardlink") + # Deleted hardlink + assert_line_exists(lines, "removed:.*input/a/hardlink") + + # Now recreate the unlinked file as a different entity with identical + # contents. + create_regular_file(archiver.input_path, hl_a, contents=b"123456") + cmd(archiver, "create", "test2", "input") + + # Compare test0 and test2. + output = cmd(archiver, "diff", "test0", "test2", exit_code=EXIT_SUCCESS) + lines = output.splitlines() + # Adding a file changes c/mtime. + assert_line_exists(lines, "[cm]time:.*[cm]time:.*input/a$") + # Different c/mtime but no apparent changes (i.e. perms) or content + # modifications should be a hint that something hard-link related is going on. + assert_line_exists(lines, "[cm]time:.*[cm]time:.*input/a/hardlink") + assert_line_not_exists(lines, "modified.*B.*input/a/hardlink") + assert_line_not_exists(lines, "[.* -> .*].*input/dir_replaced_with_file") + # The hard-link count went down, but file content isn't modified. + assert_line_exists(lines, "ctime:.*input/b/hardlink") + assert_line_not_exists(lines, ".*mtime:.*input/b/hardlink") + assert_line_not_exists(lines, "modified.*B.*input/b/hardlink") + + # Finally, compare test1 and test2. + output = cmd(archiver, "diff", "test1", "test2", exit_code=EXIT_SUCCESS) + lines = output.splitlines() + # Same situation applies as previous diff for a. + assert_line_exists(lines, "[cm]time:.*[cm]time:.*input/a$") + # From test1 to test2's POV, the a/hardlink file is a fresh new file. + assert_line_exists(lines, "added.*B.*input/a/hardlink") + # But the b/hardlink file was not modified at all. + assert_line_not_exists(lines, ".*input/b/hardlink") From 4951e1001a98d2f95da7e09eb1727be7b0fdb58b Mon Sep 17 00:00:00 2001 From: "William D. Jones" Date: Tue, 30 Sep 2025 18:12:34 -0400 Subject: [PATCH 2/2] Modify hard-link tests based on upstream feedback. --- src/borg/testsuite/archiver/diff_cmd_test.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/borg/testsuite/archiver/diff_cmd_test.py b/src/borg/testsuite/archiver/diff_cmd_test.py index 6cf53b3c0..e07f575f6 100644 --- a/src/borg/testsuite/archiver/diff_cmd_test.py +++ b/src/borg/testsuite/archiver/diff_cmd_test.py @@ -325,6 +325,9 @@ def test_sort_option(archivers, request): def test_hard_link_deletion_and_replacement(archivers, request): archiver = request.getfixturevalue(archivers) + # repo-create changes umask, so create the repo first to avoid any + # unexpected permission changes. + cmd(archiver, "repo-create", RK_ENCRYPTION) path_a = os.path.join(archiver.input_path, "a") path_b = os.path.join(archiver.input_path, "b") os.mkdir(path_a) @@ -334,9 +337,8 @@ def test_hard_link_deletion_and_replacement(archivers, request): create_regular_file(archiver.input_path, hl_a, contents=b"123456") os.link(hl_a, hl_b) - cmd(archiver, "repo-create", RK_ENCRYPTION) cmd(archiver, "create", "test0", "input") - os.unlink(hl_a) # Don't duplicate warning message- one is enough. + os.unlink(hl_a) # Don't duplicate warning message - one is enough. cmd(archiver, "create", "test1", "input") # Moral equivalent of test_multiple_link_exclusion in borg v1.x... see #8344 @@ -384,11 +386,14 @@ def test_hard_link_deletion_and_replacement(archivers, request): # modifications should be a hint that something hard-link related is going on. assert_line_exists(lines, "[cm]time:.*[cm]time:.*input/a/hardlink") assert_line_not_exists(lines, "modified.*B.*input/a/hardlink") - assert_line_not_exists(lines, "[.* -> .*].*input/dir_replaced_with_file") - # The hard-link count went down, but file content isn't modified. + assert_line_not_exists(lines, "-[r-][w-][x-].*input/a/hardlink") + # ctime changed because the hard-link count went down. But no mtime changes + # because file content isn't modified. No permissions changes either. + # This is another hint that something hard-link related changed. assert_line_exists(lines, "ctime:.*input/b/hardlink") assert_line_not_exists(lines, ".*mtime:.*input/b/hardlink") assert_line_not_exists(lines, "modified.*B.*input/b/hardlink") + assert_line_not_exists(lines, "-[r-][w-][x-].*input/b/hardlink") # Finally, compare test1 and test2. output = cmd(archiver, "diff", "test1", "test2", exit_code=EXIT_SUCCESS)