|
6 | 6 | */ |
7 | 7 |
|
8 | 8 | use std::collections::BTreeMap; |
| 9 | +use std::collections::HashMap; |
9 | 10 | use std::fs::Permissions; |
10 | 11 | use std::os::unix::ffi::OsStrExt; |
11 | 12 | use std::os::unix::fs::MetadataExt; |
@@ -45,6 +46,10 @@ impl UnprivilegedDir { |
45 | 46 | // such that they can be reconstructed by consumers of the dir |
46 | 47 | let mut escaped_paths: BTreeMap<PathBuf, PathBuf> = BTreeMap::new(); |
47 | 48 |
|
| 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 | + |
48 | 53 | std::fs::create_dir(out).context("while creating root")?; |
49 | 54 |
|
50 | 55 | std::os::unix::fs::lchown( |
@@ -97,23 +102,51 @@ impl UnprivilegedDir { |
97 | 102 | std::os::unix::fs::symlink(target, &dst) |
98 | 103 | .with_context(|| format!("while creating symlink '{}'", dst.display()))?; |
99 | 104 | } 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 | + } |
111 | 149 | } |
112 | | - // always allow read |
113 | | - mode |= 0o444; |
114 | | - // remove write bits |
115 | | - mode &= !0o222; |
116 | | - std::fs::set_permissions(&dst, Permissions::from_mode(mode))?; |
117 | 150 | } |
118 | 151 | std::os::unix::fs::lchown( |
119 | 152 | &dst, |
@@ -142,3 +175,147 @@ impl UnprivilegedDir { |
142 | 175 | Ok(()) |
143 | 176 | } |
144 | 177 | } |
| 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 | +} |
0 commit comments