| 1 | //! Shared regular-file I/O, replacing project_context's private no-follow open. |
| 2 | //! Parent containment uses the existing Fleet `WorkspaceFile` primitive. |
| 3 | //! |
| 4 | //! The caller chooses the root; links below it are refused. The root itself |
| 5 | //! may resolve through a user-selected link. Writes are not transactions: |
| 6 | //! callers needing atomic replacement must use `WorkspaceFile::replace`. |
| 7 | |
| 8 | use std::fs::{self, File}; |
| 9 | use std::io::{self, Read, Write}; |
| 10 | use std::path::Path; |
| 11 | |
| 12 | use crate::fleet::files::WorkspaceFile; |
| 13 | |
| 14 | fn target(root: &Path, path: &Path, create: bool) -> io::Result<WorkspaceFile> { |
| 15 | let relative = path.strip_prefix(root).map_err(|_| { |
| 16 | io::Error::new( |
| 17 | io::ErrorKind::InvalidInput, |
| 18 | "File must stay within its root", |
| 19 | ) |
| 20 | })?; |
| 21 | WorkspaceFile::open(root, relative, create) |
| 22 | } |
| 23 | |
| 24 | pub(crate) fn open_read(root: &Path, path: &Path) -> io::Result<File> { |
| 25 | open_regular(root, path, false) |
| 26 | } |
| 27 | |
| 28 | /// [`open_read`] for a file another process may hold open for writing, such as |
| 29 | /// a running worker's log. Windows denies those writers to [`open_read`] (it |
| 30 | /// is the protected reader), so this takes the sharing reader; the links and |
| 31 | /// regular-file policy is the same. |
| 32 | pub(crate) fn open_read_shared(root: &Path, path: &Path) -> io::Result<File> { |
| 33 | open_regular(root, path, true) |
| 34 | } |
| 35 | |
| 36 | fn open_regular(root: &Path, path: &Path, shared: bool) -> io::Result<File> { |
| 37 | let target = target(root, path, false)?; |
| 38 | let metadata = fs::symlink_metadata(path)?; |
| 39 | if metadata.file_type().is_symlink() { |
| 40 | return Err(io::Error::new( |
| 41 | io::ErrorKind::InvalidInput, |
| 42 | "Refusing symlinked file", |
| 43 | )); |
| 44 | } |
| 45 | if !metadata.is_file() { |
| 46 | return Err(io::Error::new( |
| 47 | io::ErrorKind::InvalidInput, |
| 48 | "Path is not a regular file", |
| 49 | )); |
| 50 | } |
| 51 | // The pinned parent and no-follow open enforce the same policy even if |
| 52 | // the path changes after the metadata check. The handle is checked too. |
| 53 | if shared { |
| 54 | target.open_file_shared() |
| 55 | } else { |
| 56 | target.open_file() |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | pub(crate) fn read_to_string(root: &Path, path: &Path) -> io::Result<String> { |
| 61 | let mut contents = String::new(); |
| 62 | open_read(root, path)?.read_to_string(&mut contents)?; |
| 63 | Ok(contents) |
| 64 | } |
| 65 | |
| 66 | /// Operator-owned global instructions may intentionally share a linked file. |
| 67 | /// This is not a workspace reader: it resolves links without a containment root. |
| 68 | pub(crate) fn open_user_read(path: &Path) -> io::Result<File> { |
| 69 | let resolved = fs::canonicalize(path)?; |
| 70 | let mut options = fs::OpenOptions::new(); |
| 71 | options.read(true); |
| 72 | #[cfg(unix)] |
| 73 | { |
| 74 | use std::os::unix::fs::OpenOptionsExt; |
| 75 | options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); |
| 76 | } |
| 77 | #[cfg(windows)] |
| 78 | { |
| 79 | use std::os::windows::fs::OpenOptionsExt; |
| 80 | options.custom_flags(0x0020_0000); // FILE_FLAG_OPEN_REPARSE_POINT |
| 81 | } |
| 82 | let file = options.open(resolved)?; |
| 83 | let metadata = file.metadata()?; |
| 84 | if !metadata.is_file() || crate::plugins::metadata_is_link_or_reparse(&metadata) { |
| 85 | return Err(io::Error::new( |
| 86 | io::ErrorKind::InvalidInput, |
| 87 | "Path is not a regular file", |
| 88 | )); |
| 89 | } |
| 90 | Ok(file) |
| 91 | } |
| 92 | |
| 93 | pub(crate) fn open_append(root: &Path, path: &Path) -> io::Result<File> { |
| 94 | target(root, path, true)?.open_write(true) |
| 95 | } |
| 96 | |
| 97 | pub(crate) fn write(root: &Path, path: &Path, contents: &[u8]) -> io::Result<()> { |
| 98 | let mut file = target(root, path, true)?.open_write(false)?; |
| 99 | // Validate the opened file before truncating it. |
| 100 | file.set_len(0)?; |
| 101 | file.write_all(contents) |
| 102 | } |
| 103 | |
| 104 | #[cfg(test)] |
| 105 | mod tests { |
| 106 | use super::*; |
| 107 | |
| 108 | #[test] |
| 109 | fn confined_files_support_read_append_and_truncate() { |
| 110 | let root = tempfile::tempdir().unwrap(); |
| 111 | let path = root.path().join("notes/file.md"); |
| 112 | open_append(root.path(), &path) |
| 113 | .unwrap() |
| 114 | .write_all(b"first") |
| 115 | .unwrap(); |
| 116 | open_append(root.path(), &path) |
| 117 | .unwrap() |
| 118 | .write_all(b" second") |
| 119 | .unwrap(); |
| 120 | assert_eq!(read_to_string(root.path(), &path).unwrap(), "first second"); |
| 121 | write(root.path(), &path, b"next").unwrap(); |
| 122 | assert_eq!(read_to_string(root.path(), &path).unwrap(), "next"); |
| 123 | } |
| 124 | |
| 125 | /// A running worker holds its log open for writing while the host reads |
| 126 | /// it (Windows denied that to the protected reader). |
| 127 | #[test] |
| 128 | fn a_shared_read_sees_a_file_a_writer_still_holds_open() { |
| 129 | let root = tempfile::tempdir().unwrap(); |
| 130 | let path = root.path().join("logs/worker.log"); |
| 131 | let mut writer = open_append(root.path(), &path).unwrap(); |
| 132 | writer.write_all(b"started").unwrap(); |
| 133 | writer.flush().unwrap(); |
| 134 | let mut text = String::new(); |
| 135 | open_read_shared(root.path(), &path) |
| 136 | .unwrap() |
| 137 | .read_to_string(&mut text) |
| 138 | .unwrap(); |
| 139 | assert_eq!(text, "started"); |
| 140 | writer.write_all(b" and running").unwrap(); |
| 141 | drop(writer); |
| 142 | assert_eq!( |
| 143 | read_to_string(root.path(), &path).unwrap(), |
| 144 | "started and running" |
| 145 | ); |
| 146 | assert!(open_read_shared(root.path(), root.path()).is_err()); |
| 147 | assert!(open_read_shared(root.path(), &root.path().join("../worker.log")).is_err()); |
| 148 | } |
| 149 | |
| 150 | #[test] |
| 151 | fn confined_files_refuse_paths_outside_the_root() { |
| 152 | let root = tempfile::tempdir().unwrap(); |
| 153 | let outside = tempfile::tempdir().unwrap(); |
| 154 | for path in [ |
| 155 | outside.path().join("file.md"), |
| 156 | root.path().join("../file.md"), |
| 157 | ] { |
| 158 | assert!(open_read(root.path(), &path).is_err()); |
| 159 | assert!(open_append(root.path(), &path).is_err()); |
| 160 | assert!(write(root.path(), &path, b"next").is_err()); |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | #[cfg(unix)] |
| 165 | #[test] |
| 166 | fn confined_files_validate_handles_before_truncation() { |
| 167 | let root = tempfile::tempdir().unwrap(); |
| 168 | let outside = tempfile::tempdir().unwrap(); |
| 169 | let original = outside.path().join("original.md"); |
| 170 | fs::write(&original, "original").unwrap(); |
| 171 | let path = root.path().join("linked.md"); |
| 172 | fs::hard_link(&original, &path).unwrap(); |
| 173 | assert!(write(root.path(), &path, b"next").is_err()); |
| 174 | assert_eq!(fs::read_to_string(original).unwrap(), "original"); |
| 175 | assert!(open_read(root.path(), root.path()).is_err()); |
| 176 | } |
| 177 | |
| 178 | #[cfg(unix)] |
| 179 | #[test] |
| 180 | fn confined_files_refuse_links_even_within_the_root() { |
| 181 | use std::os::unix::fs::symlink; |
| 182 | let root = tempfile::tempdir().unwrap(); |
| 183 | let directory = root.path().join("real"); |
| 184 | fs::create_dir(&directory).unwrap(); |
| 185 | let original = directory.join("file.md"); |
| 186 | fs::write(&original, "original").unwrap(); |
| 187 | symlink(&directory, root.path().join("linked-dir")).unwrap(); |
| 188 | symlink(&original, root.path().join("linked.md")).unwrap(); |
| 189 | for path in [ |
| 190 | root.path().join("linked-dir/file.md"), |
| 191 | root.path().join("linked.md"), |
| 192 | ] { |
| 193 | assert!(open_read(root.path(), &path).is_err()); |
| 194 | assert!(open_read_shared(root.path(), &path).is_err()); |
| 195 | assert!(open_append(root.path(), &path).is_err()); |
| 196 | assert!(write(root.path(), &path, b"next").is_err()); |
| 197 | assert_eq!(fs::read_to_string(&original).unwrap(), "original"); |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | #[cfg(unix)] |
| 202 | #[test] |
| 203 | fn confined_writes_do_not_require_read_permission() { |
| 204 | use std::os::unix::fs::PermissionsExt; |
| 205 | let root = tempfile::tempdir().unwrap(); |
| 206 | let path = root.path().join("notes.md"); |
| 207 | fs::write(&path, "first").unwrap(); |
| 208 | fs::set_permissions(&path, fs::Permissions::from_mode(0o200)).unwrap(); |
| 209 | let mut file = open_append(root.path(), &path).unwrap(); |
| 210 | assert!(file.read(&mut [0]).is_err()); |
| 211 | file.write_all(b" second").unwrap(); |
| 212 | drop(file); |
| 213 | fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); |
| 214 | assert_eq!(fs::read_to_string(&path).unwrap(), "first second"); |
| 215 | fs::set_permissions(&path, fs::Permissions::from_mode(0o200)).unwrap(); |
| 216 | write(root.path(), &path, b"next").unwrap(); |
| 217 | fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); |
| 218 | assert_eq!(fs::read_to_string(&path).unwrap(), "next"); |
| 219 | } |
| 220 | } |
| 221 |