Skip to content

Commit 11bbef7

Browse files
authored
Fix copy hardlinks in shared packages (BobBuildTool#680)
2 parents 71a5860 + 86decfa commit 11bbef7

2 files changed

Lines changed: 129 additions & 2 deletions

File tree

pym/bob/share.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
import os, os.path
1111
import json
1212
import shutil
13+
import stat
1314
import sys
1415
import tempfile
1516

1617
warnRepoSize = WarnOnce("The shared repository is over its quota. Run 'bob clean --shared' to free disk space!")
1718
warnGcDidNotHelp = WarnOnce("The automatic garbage collection of the shared repository was unable to free enough space. Run 'bob clean --shared' manually.")
1819
warnNoShareConfigured = Warn("No shared directory configured! Nothing cleaned.")
20+
warnEscapedHardLink = Warn("The file has hard links outside the workspace! Review the packageScript to fix the problem.")
1921

2022
if sys.platform == 'win32':
2123
import msvcrt
@@ -57,6 +59,52 @@ def __exit__(self, exc_type, exc_value, traceback):
5759
self.fd.close()
5860

5961

62+
if isWindows():
63+
class CopyMachine:
64+
def __call__(self, src, dst):
65+
return shutil.copy2(src, dst)
66+
def postCopyCheck(self):
67+
pass
68+
else:
69+
class CopyMachine:
70+
def __init__(self):
71+
self.hard_links = {}
72+
73+
def __call__(self, src, dst):
74+
if os.path.isdir(dst):
75+
dst = os.path.join(dst, os.path.basename(src))
76+
77+
st = os.stat(src)
78+
if st.st_nlink <= 1 or not self.__handle_hard_link(src, dst, st):
79+
shutil.copyfile(src, dst)
80+
shutil.copystat(src, dst)
81+
82+
return dst
83+
84+
def __handle_hard_link(self, src, dst, st):
85+
key = (st.st_ino, st.st_dev)
86+
file_rec = self.hard_links.get(key)
87+
if file_rec is not None:
88+
# We've seen this one... We assume that we don't cross mount
89+
# points at destination!
90+
existing, remaining = file_rec
91+
os.link(existing, dst)
92+
if remaining <= 1:
93+
del self.hard_links[key]
94+
else:
95+
file_rec[1] -= 1
96+
return True
97+
else:
98+
# First time we visited this hard-linked file. Remember with
99+
# remaining hard links.
100+
self.hard_links[key] = [dst, st.st_nlink - 1]
101+
return False
102+
103+
def postCopyCheck(self):
104+
for _, (name, _links) in self.hard_links.items():
105+
warnEscapedHardLink.show(name)
106+
107+
60108
def sameWorkspace(link, sharePath):
61109
"""Is the workspace shared and points to sharePath?
62110
@@ -220,11 +268,14 @@ def installSharedPackage(self, workspace, buildId, sharedHash, mayMove):
220268
# Might not exist if workspace was emtpy
221269
if os.path.exists(cacheBinSrc):
222270
shutil.copyfile(cacheBinSrc, cacheBinDst)
271+
272+
copyFun = CopyMachine()
223273
if mayMove:
224-
shutil.move(workspace, tmpSharedPath)
274+
shutil.move(workspace, tmpSharedPath, copy_function=copyFun)
225275
else:
226276
shutil.copytree(workspace, os.path.join(tmpSharedPath, "workspace"),
227-
symlinks=True)
277+
symlinks=True, copy_function=copyFun)
278+
copyFun.postCopyCheck()
228279

229280
# Cerify the result hash and count file system size. The user
230281
# could have an incompatible file system at the destination.

test/unit/test_share.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Bob Build Tool
2+
# Copyright (C) 2025 Jan Klötzke
3+
#
4+
# SPDX-License-Identifier: GPL-3.0-or-later
5+
from tempfile import NamedTemporaryFile, TemporaryDirectory
6+
from unittest import TestCase, skipIf
7+
from unittest.mock import MagicMock, patch
8+
import os
9+
import sys
10+
11+
from bob.share import LocalShare
12+
from bob.utils import hashDirectory
13+
from bob.errors import BuildError
14+
15+
class TestLocalShare(TestCase):
16+
17+
def setUp(self):
18+
# create repo
19+
self.repo = TemporaryDirectory()
20+
self.share = LocalShare({ 'path' : self.repo.name })
21+
self.pkg_tmp = TemporaryDirectory()
22+
self.pkg = self.pkg_tmp.name
23+
with open(os.path.join(self.pkg, "audit.json.gz"), "wb") as f:
24+
pass
25+
self.workspace = os.path.join(self.pkg, "workspace")
26+
os.mkdir(self.workspace)
27+
28+
def tearDown(self):
29+
self.pkg_tmp.cleanup()
30+
self.repo.cleanup()
31+
32+
@skipIf(sys.platform.startswith("win"), "requires POSIX platform")
33+
def testInstallHardLinks(self):
34+
"""Hard links are preserved when copying"""
35+
with open(os.path.join(self.workspace, "a"), "wb") as f:
36+
f.write(b'a')
37+
os.link(os.path.join(self.workspace, "a"), os.path.join(self.workspace, "b"))
38+
os.link(os.path.join(self.workspace, "a"), os.path.join(self.workspace, "c"))
39+
40+
with patch('bob.share.warnEscapedHardLink') as warning:
41+
warning.show = MagicMock()
42+
bid = b'a'*20
43+
self.share.installSharedPackage(self.workspace, bid, hashDirectory(self.workspace), False)
44+
warning.show.assert_not_called()
45+
46+
sharePath = self.share._LocalShare__buildPath(bid)
47+
s1 = os.stat(os.path.join(sharePath, "workspace", "a"))
48+
s2 = os.stat(os.path.join(sharePath, "workspace", "b"))
49+
s3 = os.stat(os.path.join(sharePath, "workspace", "c"))
50+
self.assertEqual(s1.st_nlink, 3)
51+
self.assertEqual(s2.st_nlink, 3)
52+
self.assertEqual(s3.st_nlink, 3)
53+
self.assertEqual(s1.st_dev, s2.st_dev)
54+
self.assertEqual(s1.st_ino, s2.st_ino)
55+
self.assertEqual(s2.st_dev, s3.st_dev)
56+
self.assertEqual(s2.st_ino, s3.st_ino)
57+
58+
@skipIf(sys.platform.startswith("win"), "requires POSIX platform")
59+
def testInstallHardLinkOutsideWorkspace(self):
60+
"""Hard links outside the workspace create a warning"""
61+
with open(os.path.join(self.pkg, "outside"), "wb") as f:
62+
f.write(b'a')
63+
os.link(os.path.join(self.pkg, "outside"), os.path.join(self.workspace, "file"))
64+
65+
with patch('bob.share.warnEscapedHardLink') as warning:
66+
warning.show = MagicMock()
67+
bid = b'a'*20
68+
self.share.installSharedPackage(self.workspace, bid, hashDirectory(self.workspace), False)
69+
warning.show.assert_called()
70+
71+
def testInstallCorrupted(self):
72+
"""Changes to the hash sum at the destination are detected"""
73+
with self.assertRaises(BuildError):
74+
self.share.installSharedPackage(self.workspace, b'a'*20, b'0'*20, False)
75+
with self.assertRaises(BuildError):
76+
self.share.installSharedPackage(self.workspace, b'a'*20, b'0'*20, True)

0 commit comments

Comments
 (0)