Skip to content

Commit 620699c

Browse files
justintrudellmeta-codesync[bot]
authored andcommitted
[antlir][unprivileged_dir] Add support for hardlinks
Test Plan: I added some unit tests to the package Rust impl because this can't fully be tested in-layer (see comment) ``` $ buck2 test mode/opt fbcode//antlir/antlir2/test_images/package/unprivileged_dir: ``` https://www.internalfb.com/intern/testinfra/testrun/12666374087883764 ``` $ buck2 test fbcode//antlir/antlir2/test_images/package/unprivileged_dir: ``` https://www.internalfb.com/intern/testinfra/testrun/9570149344331190 ``` $ buck test fbcode//antlir/antlir2/antlir2_packager:antlir2-packager-unittest ``` https://www.internalfb.com/intern/testinfra/testrun/14355223948153901 ``` $ buck test mode/opt fbcode//antlir/antlir2/antlir2_packager:antlir2-packager-unittest ``` https://www.internalfb.com/intern/testinfra/testrun/7318349714038736 Reviewed By: vmagro Differential Revision: D89217414 fbshipit-source-id: 7ce38edd94258b75c721e506a73f16078e2263fa
1 parent 21d67d3 commit 620699c

File tree

2 files changed

+196
-16
lines changed

2 files changed

+196
-16
lines changed

antlir/antlir2/antlir2_packager/src/unprivileged_dir.rs

Lines changed: 193 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
use std::collections::BTreeMap;
9+
use std::collections::HashMap;
910
use std::fs::Permissions;
1011
use std::os::unix::ffi::OsStrExt;
1112
use std::os::unix::fs::MetadataExt;
@@ -45,6 +46,10 @@ impl UnprivilegedDir {
4546
// such that they can be reconstructed by consumers of the dir
4647
let mut escaped_paths: BTreeMap<PathBuf, PathBuf> = BTreeMap::new();
4748

49+
// Track inode -> destination path for hardlink preservation
50+
// Key: (device, inode), Value: first destination path for this inode
51+
let mut inode_to_dst: HashMap<(u64, u64), PathBuf> = HashMap::new();
52+
4853
std::fs::create_dir(out).context("while creating root")?;
4954

5055
std::os::unix::fs::lchown(
@@ -97,23 +102,51 @@ impl UnprivilegedDir {
97102
std::os::unix::fs::symlink(target, &dst)
98103
.with_context(|| format!("while creating symlink '{}'", dst.display()))?;
99104
} else if entry.file_type().is_file() {
100-
std::fs::copy(entry.path(), &dst).with_context(|| {
101-
format!(
102-
"while copying file '{}' -> '{}'",
103-
entry.path().display(),
104-
dst.display()
105-
)
106-
})?;
107-
let mut mode = entry.metadata()?.mode();
108-
// preserve executable bit
109-
if (mode & 0o111) != 0 {
110-
mode |= 0o111;
105+
let metadata = entry.metadata()?;
106+
let nlink = metadata.nlink();
107+
108+
// Check if this is a hardlink we've already seen
109+
let inode_key = (metadata.dev(), metadata.ino());
110+
let existing_hardlink = if nlink > 1 {
111+
inode_to_dst.get(&inode_key).cloned()
112+
} else {
113+
None
114+
};
115+
116+
if let Some(existing_dst) = existing_hardlink {
117+
// This is a hardlink to an already-copied file - create hardlink
118+
std::fs::hard_link(&existing_dst, &dst).with_context(|| {
119+
format!(
120+
"while creating hardlink '{}' -> '{}'",
121+
dst.display(),
122+
existing_dst.display()
123+
)
124+
})?;
125+
} else {
126+
// First occurrence of this inode (or not a hardlink) - copy the file
127+
std::fs::copy(entry.path(), &dst).with_context(|| {
128+
format!(
129+
"while copying file '{}' -> '{}'",
130+
entry.path().display(),
131+
dst.display()
132+
)
133+
})?;
134+
let mut mode = metadata.mode();
135+
// preserve executable bit
136+
if (mode & 0o111) != 0 {
137+
mode |= 0o111;
138+
}
139+
// always allow read
140+
mode |= 0o444;
141+
// remove write bits
142+
mode &= !0o222;
143+
std::fs::set_permissions(&dst, Permissions::from_mode(mode))?;
144+
145+
// Track this inode for future hardlinks
146+
if nlink > 1 {
147+
inode_to_dst.insert(inode_key, dst.clone());
148+
}
111149
}
112-
// always allow read
113-
mode |= 0o444;
114-
// remove write bits
115-
mode &= !0o222;
116-
std::fs::set_permissions(&dst, Permissions::from_mode(mode))?;
117150
}
118151
std::os::unix::fs::lchown(
119152
&dst,
@@ -142,3 +175,147 @@ impl UnprivilegedDir {
142175
Ok(())
143176
}
144177
}
178+
179+
#[cfg(test)]
180+
mod tests {
181+
use std::ffi::OsStr;
182+
use std::fs::File;
183+
use std::io::Write;
184+
use std::os::unix::ffi::OsStrExt;
185+
use std::os::unix::fs::MetadataExt;
186+
use std::os::unix::fs::PermissionsExt;
187+
188+
use tempfile::TempDir;
189+
190+
use super::*;
191+
192+
#[test]
193+
fn build_copies_files_symlinks_and_permissions() -> Result<()> {
194+
let layer = TempDir::new()?;
195+
let out = TempDir::new()?;
196+
let out_path = out.path().join("output");
197+
198+
File::create(layer.path().join("test.txt"))?.write_all(b"hello world")?;
199+
200+
File::create(layer.path().join("target.txt"))?.write_all(b"target content")?;
201+
std::os::unix::fs::symlink("target.txt", layer.path().join("link.txt"))?;
202+
203+
File::create(layer.path().join("script.sh"))?.write_all(b"#!/bin/bash\necho hello")?;
204+
std::fs::set_permissions(
205+
layer.path().join("script.sh"),
206+
Permissions::from_mode(0o755),
207+
)?;
208+
209+
File::create(layer.path().join("writable.txt"))?.write_all(b"content")?;
210+
std::fs::set_permissions(
211+
layer.path().join("writable.txt"),
212+
Permissions::from_mode(0o644),
213+
)?;
214+
215+
let unprivileged_dir = UnprivilegedDir {
216+
base64_encoded_filenames: None,
217+
};
218+
unprivileged_dir.build(&out_path, layer.path(), None)?;
219+
220+
// file contents are preserved
221+
assert_eq!(
222+
std::fs::read_to_string(out_path.join("test.txt"))?,
223+
"hello world"
224+
);
225+
226+
// symlinks are preserved
227+
assert_eq!(
228+
std::fs::read_link(out_path.join("link.txt"))?,
229+
PathBuf::from("target.txt")
230+
);
231+
232+
assert_eq!(
233+
std::fs::metadata(out_path.join("script.sh"))?
234+
.permissions()
235+
.mode()
236+
& 0o777,
237+
0o555
238+
);
239+
240+
// writable bits are removed, executable bits are preserved
241+
let writable_mode = std::fs::metadata(out_path.join("writable.txt"))?
242+
.permissions()
243+
.mode();
244+
assert_eq!(writable_mode & 0o222, 0);
245+
assert_eq!(writable_mode & 0o444, 0o444);
246+
247+
Ok(())
248+
}
249+
250+
#[test]
251+
fn build_encodes_filenames_with_backslash() -> Result<()> {
252+
let layer = TempDir::new()?;
253+
let out = TempDir::new()?;
254+
let out_path = out.path().join("output");
255+
let mapping_file = out.path().join("mapping.json");
256+
257+
let filename_bytes: &[u8] = b"file\\with\\backslash.txt";
258+
let filename = OsStr::from_bytes(filename_bytes);
259+
let file_path = layer.path().join(filename);
260+
let mut file = File::create(&file_path)?;
261+
file.write_all(b"content with backslash filename")?;
262+
drop(file);
263+
264+
let unprivileged_dir = UnprivilegedDir {
265+
base64_encoded_filenames: Some(mapping_file.clone()),
266+
};
267+
268+
unprivileged_dir.build(&out_path, layer.path(), None)?;
269+
270+
assert!(mapping_file.exists());
271+
272+
let mapping: BTreeMap<PathBuf, PathBuf> =
273+
serde_json::from_str(&std::fs::read_to_string(&mapping_file)?)?;
274+
275+
assert!(!mapping.is_empty());
276+
let original_path = PathBuf::from("/").join(filename);
277+
assert!(mapping.values().any(|v| *v == original_path));
278+
279+
Ok(())
280+
}
281+
282+
#[test]
283+
fn build_preserves_hardlinks() -> Result<()> {
284+
let layer = TempDir::new()?;
285+
let out = TempDir::new()?;
286+
let out_path = out.path().join("output");
287+
288+
// Create a file with multiple hardlinks
289+
let mut file = File::create(layer.path().join("original.txt"))?;
290+
file.write_all(b"shared by many")?;
291+
drop(file);
292+
std::fs::hard_link(
293+
layer.path().join("original.txt"),
294+
layer.path().join("link1.txt"),
295+
)?;
296+
std::fs::hard_link(
297+
layer.path().join("original.txt"),
298+
layer.path().join("link2.txt"),
299+
)?;
300+
std::fs::hard_link(
301+
layer.path().join("original.txt"),
302+
layer.path().join("link3.txt"),
303+
)?;
304+
305+
let unprivileged_dir = UnprivilegedDir {
306+
base64_encoded_filenames: None,
307+
};
308+
309+
unprivileged_dir.build(&out_path, layer.path(), None)?;
310+
311+
// Verify all files share the same inode (are hardlinks)
312+
let meta_orig = std::fs::metadata(out_path.join("original.txt"))?;
313+
for link in ["link1.txt", "link2.txt", "link3.txt"] {
314+
let link_path = out_path.join(link);
315+
let meta = std::fs::metadata(&link_path)?;
316+
assert_eq!(meta.ino(), meta_orig.ino());
317+
}
318+
319+
Ok(())
320+
}
321+
}

antlir/antlir2/test_images/package/unprivileged_dir/test_unprivileged_dir.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,6 @@ def test_standard(self) -> None:
107107
for relpath, stat_info in stats.items():
108108
self.assertEqual(uid, stat_info.st_uid, f"{relpath} owned by wrong user")
109109
self.assertEqual(gid, stat_info.st_gid, f"{relpath} owned by wrong group")
110+
111+
# Note: We can't currently verify hardlinks because feature.install uses
112+
# copy_with_metadata, which doesn't preserve them

0 commit comments

Comments
 (0)