| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | |
|
| 10 | |
|
| 11 | |
|
| 12 | |
|
| 13 | | use crate::db::{self, accounts::Account, repos::RepoRow}; |
| 14 | | use crate::repos::edits::{self, Signer}; |
| 15 | | use crate::repos::{RepoError, RepoName}; |
| 16 | | use crate::Shared; |
| 17 | | use hub_api::Visibility; |
| 18 | | use oxy_core::objects::NodeKind; |
| 19 | | use oxy_core::repo::FileEdit; |
| 20 | | use oxy_core::state::FileContent; |
| 21 | |
|
| 22 | |
|
| 23 | | pub const MAX_FILES: usize = 50; |
| 24 | |
|
| 25 | | pub const MAX_FILE_BYTES: usize = 1 << 20; |
| 26 | |
|
| 27 | | pub const MAX_TITLE: usize = 200; |
| 28 | | pub const MAX_DESCRIPTION: usize = 2000; |
| 29 | |
|
| 30 | | const SHOWN_BYTES: u64 = 512 * 1024; |
| 31 | |
|
| 32 | |
|
| 33 | | #[derive(Debug)] |
| 34 | | pub enum DropError { |
| 35 | | |
| 36 | | Bad(String), |
| 37 | | Repo(RepoError), |
| 38 | | Db(sqlx::Error), |
| 39 | | } |
| 40 | |
|
| 41 | | impl std::fmt::Display for DropError { |
| 42 | 0× | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 43 | 0× | match self { |
| 44 | 0× | DropError::Bad(m) => f.write_str(m), |
| 45 | 0× | DropError::Repo(e) => write!(f, "{e}"), |
| 46 | 0× | DropError::Db(e) => write!(f, "{e}"), |
| 47 | | } |
| 48 | 0× | } |
| 49 | | } |
| 50 | |
|
| 51 | | impl From<sqlx::Error> for DropError { |
| 52 | 0× | fn from(e: sqlx::Error) -> DropError { |
| 53 | 0× | DropError::Db(e) |
| 54 | 0× | } |
| 55 | | } |
| 56 | |
|
| 57 | | impl From<RepoError> for DropError { |
| 58 | 0× | fn from(e: RepoError) -> DropError { |
| 59 | 0× | DropError::Repo(e) |
| 60 | 0× | } |
| 61 | | } |
| 62 | |
|
| 63 | 0× | fn bad(msg: impl Into<String>) -> DropError { |
| 64 | 0× | DropError::Bad(msg.into()) |
| 65 | 0× | } |
| 66 | |
|
| 67 | |
|
| 68 | 16× | pub fn visibility_label(row: &RepoRow) -> &'static str { |
| 69 | 16× | if row.visibility() == Visibility::Public { |
| 70 | 5× | "public" |
| 71 | | } else { |
| 72 | 11× | "secret" |
| 73 | | } |
| 74 | 16× | } |
| 75 | |
|
| 76 | 9× | fn visibility(secret: bool) -> Visibility { |
| 77 | 9× | if secret { |
| 78 | 4× | Visibility::Private |
| 79 | | } else { |
| 80 | 5× | Visibility::Public |
| 81 | | } |
| 82 | 9× | } |
| 83 | |
|
| 84 | |
|
| 85 | 11× | pub fn id_of(row: &RepoRow) -> &str { |
| 86 | 11× | row.name.split_once('/').map(|(_, id)| id).unwrap_or(&row.name) |
| 87 | 11× | } |
| 88 | |
|
| 89 | |
|
| 90 | 91× | pub fn url_of(row: &RepoRow) -> String { |
| 91 | 91× | format!("/{}/{}", row.owner, row.name) |
| 92 | 91× | } |
| 93 | |
|
| 94 | |
|
| 95 | 36× | pub fn shown_title(row: &RepoRow, files: &[DropFile]) -> String { |
| 96 | 36× | if !row.title.is_empty() { |
| 97 | 33× | row.title.clone() |
| 98 | 3× | } else if let Some(f) = files.first() { |
| 99 | 3× | f.path.clone() |
| 100 | | } else { |
| 101 | 0× | id_of(row).to_string() |
| 102 | | } |
| 103 | 36× | } |
| 104 | |
|
| 105 | |
|
| 106 | | #[derive(Clone, Debug)] |
| 107 | | pub struct DropFile { |
| 108 | | pub path: String, |
| 109 | | pub size: u64, |
| 110 | | |
| 111 | | pub text: Option<String>, |
| 112 | | } |
| 113 | |
|
| 114 | |
|
| 115 | | #[derive(Clone, Debug)] |
| 116 | | pub struct NewFile { |
| 117 | | pub name: String, |
| 118 | | pub content: String, |
| 119 | | } |
| 120 | |
|
| 121 | |
|
| 122 | 18× | pub fn check_name(name: &str) -> Result<String, String> { |
| 123 | 18× | let name = name.trim().trim_matches('/'); |
| 124 | 18× | if name.is_empty() { |
| 125 | 0× | return Err("Every file needs a name.".into()); |
| 126 | 18× | } |
| 127 | 18× | if name.len() > 255 { |
| 128 | 0× | return Err(format!("{name:.40}…: a file name is at most 255 bytes.")); |
| 129 | 18× | } |
| 130 | 128× | if name.chars().any(|c| c.is_control() || c == '\\') |
| 131 | 18× | || name.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == oxy_core::repo::OXY_DIR || p.trim() != p) |
| 132 | | { |
| 133 | 0× | return Err(format!("{name:?} is not a file name: use letters, digits and punctuation, with / between folders.")); |
| 134 | 18× | } |
| 135 | 18× | Ok(name.to_string()) |
| 136 | 18× | } |
| 137 | |
|
| 138 | |
|
| 139 | |
|
| 140 | 8× | pub fn check_files(files: &[NewFile], allow_empty: bool) -> Result<Vec<NewFile>, String> { |
| 141 | 8× | if files.is_empty() && !allow_empty { |
| 142 | 0× | return Err("A drop needs at least one file.".into()); |
| 143 | 8× | } |
| 144 | 8× | if files.len() > MAX_FILES { |
| 145 | 0× | return Err(format!("A drop takes at most {MAX_FILES} files at a time.")); |
| 146 | 8× | } |
| 147 | 8× | let mut out: Vec<NewFile> = Vec::with_capacity(files.len()); |
| 148 | 13× | for f in files { |
| 149 | 13× | let name = check_name(&f.name)?; |
| 150 | 13× | if f.content.len() > MAX_FILE_BYTES { |
| 151 | 0× | return Err(format!("{name} is larger than {} KiB; push larger files with oxy.", MAX_FILE_BYTES / 1024)); |
| 152 | 13× | } |
| 153 | 13× | if out.iter().any(|o| o.name == name) { |
| 154 | 0× | return Err(format!("{name} is named twice.")); |
| 155 | 13× | } |
| 156 | | |
| 157 | 13× | out.push(NewFile { name, content: f.content.replace("\r\n", "\n") }); |
| 158 | | } |
| 159 | 8× | scan(&out)?; |
| 160 | 7× | Ok(out) |
| 161 | 8× | } |
| 162 | |
|
| 163 | |
|
| 164 | |
|
| 165 | |
|
| 166 | 11× | pub fn scan(files: &[NewFile]) -> Result<(), String> { |
| 167 | 11× | let rules = crate::push_protection::rules(); |
| 168 | 11× | let mut found = Vec::new(); |
| 169 | 16× | for f in files { |
| 170 | 16× | for h in crate::push_protection::scan_text(rules, f.content.as_bytes()) { |
| 171 | 2× | let rule = &rules.rules[h.rule]; |
| 172 | 2× | found.push(format!("{}:{}: {} (fingerprint {})", f.name, h.line, rule.description, h.fingerprint)); |
| 173 | 2× | } |
| 174 | | } |
| 175 | 11× | if found.is_empty() { |
| 176 | 9× | return Ok(()); |
| 177 | 2× | } |
| 178 | 2× | Err(format!( |
| 179 | | "Push protection refused this: {} like {}, and nothing was saved. {} Take {} out (read credentials from the environment or a secret store), and if {} real, revoke or rotate {}.", |
| 180 | 2× | if found.len() == 1 { "a value looks".to_string() } else { format!("{} values look", found.len()) }, |
| 181 | 2× | if found.len() == 1 { "a secret" } else { "secrets" }, |
| 182 | 2× | found.join("; "), |
| 183 | 2× | if found.len() == 1 { "it" } else { "them" }, |
| 184 | 2× | if found.len() == 1 { "it is" } else { "one is" }, |
| 185 | 2× | if found.len() == 1 { "it" } else { "it as well" }, |
| 186 | | )) |
| 187 | 11× | } |
| 188 | |
|
| 189 | 9× | fn check_text(title: &str, description: &str) -> Result<(), String> { |
| 190 | 9× | if title.chars().count() > MAX_TITLE { |
| 191 | 0× | return Err(format!("A title is at most {MAX_TITLE} characters.")); |
| 192 | 9× | } |
| 193 | 9× | if description.chars().count() > MAX_DESCRIPTION { |
| 194 | 0× | return Err(format!("A description is at most {MAX_DESCRIPTION} characters.")); |
| 195 | 9× | } |
| 196 | 9× | Ok(()) |
| 197 | 9× | } |
| 198 | |
|
| 199 | |
|
| 200 | 8× | async fn signer(app: &Shared, account: &Account) -> Result<Signer, DropError> { |
| 201 | 8× | crate::web::edit::signer_for(app, account.id).await.map_err(DropError::Bad) |
| 202 | 8× | } |
| 203 | |
|
| 204 | |
|
| 205 | |
|
| 206 | |
|
| 207 | 6× | pub async fn create( |
| 208 | 6× | app: &Shared, |
| 209 | 6× | account: &Account, |
| 210 | 6× | title: &str, |
| 211 | 6× | description: &str, |
| 212 | 6× | secret: bool, |
| 213 | 6× | files: &[NewFile], |
| 214 | 6× | ) -> Result<RepoRow, DropError> { |
| 215 | 6× | if account.blocked() { |
| 216 | 0× | return Err(bad("This account cannot make drops.")); |
| 217 | 6× | } |
| 218 | 6× | let (title, description) = (title.trim(), description.trim()); |
| 219 | 6× | check_text(title, description).map_err(DropError::Bad)?; |
| 220 | 6× | let files = check_files(files, false).map_err(DropError::Bad)?; |
| 221 | 6× | let signer = signer(app, account).await?; |
| 222 | | |
| 223 | 5× | let mut found = None; |
| 224 | 5× | for _ in 0..8 { |
| 225 | 5× | let id = crate::auth::random_hex(12); |
| 226 | 5× | let Some(name) = RepoName::drop(&account.username, &id) else { break }; |
| 227 | 5× | if db::repos::drop_by_id(&app.db, &id).await?.is_none() && !app.repos.exists(&name) { |
| 228 | 5× | found = Some(name); |
| 229 | 5× | break; |
| 230 | 0× | } |
| 231 | | } |
| 232 | 5× | let Some(name) = found else { return Err(bad("No free drop id was found; try again.")) }; |
| 233 | 5× | let mut repo_id = [0u8; 16]; |
| 234 | 5× | rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut repo_id); |
| 235 | 5× | app.repos.create_async(&name, Some(repo_id)).await?; |
| 236 | 5× | let id = name.drop_id().unwrap_or_default().to_string(); |
| 237 | 5× | let row = match db::repos::create_drop(&app.db, &account.username, &id, title, description, visibility(secret)).await { |
| 238 | 5× | Ok(row) => row, |
| 239 | 0× | Err(e) => { |
| 240 | 0× | let _ = std::fs::remove_dir_all(app.repos.dir(&name)); |
| 241 | 0× | return Err(e.into()); |
| 242 | | } |
| 243 | | }; |
| 244 | 5× | let edits: Vec<FileEdit> = |
| 245 | 7× | files.iter().map(|f| FileEdit { path: f.name.clone(), content: Some(f.content.clone().into_bytes()) }).collect(); |
| 246 | 5× | let message = if title.is_empty() { "Create the drop".to_string() } else { format!("Create {title}") }; |
| 247 | 5× | let b = row.default_bookmark.clone(); |
| 248 | 5× | match edits::commit(&app.repos, &name, b.clone(), b, edits, message, signer).await { |
| 249 | 5× | Ok(done) => { |
| 250 | 5× | recorded(app, account, &row, &done, "drop.created", visibility_label(&row)).await; |
| 251 | 5× | Ok(db::repos::get_by_id(&app.db, row.id).await?.unwrap_or(row)) |
| 252 | | } |
| 253 | 0× | Err(e) => { |
| 254 | 0× | db::repos::delete(&app.db, row.id).await.ok(); |
| 255 | 0× | let _ = std::fs::remove_dir_all(app.repos.dir(&name)); |
| 256 | 0× | Err(DropError::Bad(format!("The drop was not made: {e}"))) |
| 257 | | } |
| 258 | | } |
| 259 | 6× | } |
| 260 | |
|
| 261 | |
|
| 262 | |
|
| 263 | |
|
| 264 | 1× | pub async fn fork(app: &Shared, account: &Account, source: &RepoRow) -> Result<RepoRow, DropError> { |
| 265 | 1× | if account.blocked() { |
| 266 | 0× | return Err(bad("This account cannot make drops.")); |
| 267 | 1× | } |
| 268 | 1× | let Some(from) = RepoName::new(&source.owner, &source.name) else { return Err(bad("That is not a drop.")) }; |
| 269 | 1× | let mut found = None; |
| 270 | 1× | for _ in 0..8 { |
| 271 | 1× | let id = crate::auth::random_hex(12); |
| 272 | 1× | let Some(name) = RepoName::drop(&account.username, &id) else { break }; |
| 273 | 1× | if db::repos::drop_by_id(&app.db, &id).await?.is_none() && !app.repos.exists(&name) { |
| 274 | 1× | found = Some(name); |
| 275 | 1× | break; |
| 276 | 0× | } |
| 277 | | } |
| 278 | 1× | let Some(name) = found else { return Err(bad("No free drop id was found; try again.")) }; |
| 279 | 1× | app.repos.fork(&from, &name).await?; |
| 280 | 1× | let id = name.drop_id().unwrap_or_default().to_string(); |
| 281 | 1× | let row = match db::repos::create_drop(&app.db, &account.username, &id, &source.title, &source.description, source.visibility()).await { |
| 282 | 1× | Ok(row) => row, |
| 283 | 0× | Err(e) => { |
| 284 | 0× | let _ = std::fs::remove_dir_all(app.repos.dir(&name)); |
| 285 | 0× | return Err(e.into()); |
| 286 | | } |
| 287 | | }; |
| 288 | 1× | db::repos::set_forked_from(&app.db, row.id, source.id).await?; |
| 289 | 1× | db::accounts::audit(&app.db, Some(account), "drop.forked", &url_of(&row), &url_of(source)).await.ok(); |
| 290 | 1× | Ok(db::repos::get_by_id(&app.db, row.id).await?.unwrap_or(row)) |
| 291 | 1× | } |
| 292 | |
|
| 293 | |
|
| 294 | | pub struct Update { |
| 295 | | pub title: String, |
| 296 | | pub description: String, |
| 297 | | pub secret: bool, |
| 298 | | |
| 299 | | pub files: Vec<FileEdit>, |
| 300 | | pub message: String, |
| 301 | | } |
| 302 | |
|
| 303 | |
|
| 304 | |
|
| 305 | 3× | pub async fn update(app: &Shared, account: &Account, row: &RepoRow, u: Update) -> Result<(), DropError> { |
| 306 | 3× | let (title, description) = (u.title.trim(), u.description.trim()); |
| 307 | 3× | check_text(title, description).map_err(DropError::Bad)?; |
| 308 | 3× | if !u.files.is_empty() { |
| 309 | 3× | if u.files.len() > MAX_FILES { |
| 310 | 0× | return Err(bad(format!("A drop takes at most {MAX_FILES} files at a time."))); |
| 311 | 3× | } |
| 312 | 3× | let written: Vec<NewFile> = u |
| 313 | 3× | .files |
| 314 | 3× | .iter() |
| 315 | 3× | .filter_map(|e| e.content.as_ref().map(|c| NewFile { name: e.path.clone(), content: String::from_utf8_lossy(c).into_owned() })) |
| 316 | 3× | .collect(); |
| 317 | 3× | for f in &written { |
| 318 | 3× | check_name(&f.name).map_err(DropError::Bad)?; |
| 319 | 3× | if f.content.len() > MAX_FILE_BYTES { |
| 320 | 0× | return Err(bad(format!("{} is larger than {} KiB; push larger files with oxy.", f.name, MAX_FILE_BYTES / 1024))); |
| 321 | 3× | } |
| 322 | | } |
| 323 | 3× | scan(&written).map_err(DropError::Bad)?; |
| 324 | 2× | let signer = signer(app, account).await?; |
| 325 | 2× | let name = RepoName::new(&row.owner, &row.name).ok_or_else(|| bad("not a drop"))?; |
| 326 | 2× | let message = if u.message.trim().is_empty() { |
| 327 | 1× | format!("Edit {}", if title.is_empty() { id_of(row) } else { title }) |
| 328 | | } else { |
| 329 | 1× | u.message.trim().to_string() |
| 330 | | }; |
| 331 | | |
| 332 | 2× | if u.files.iter().any(|e| e.content.is_none()) { |
| 333 | 0× | let (_, current) = files(app, row, "").await?; |
| 334 | 0× | let left = current.iter().filter(|c| !u.files.iter().any(|e| e.path == c.path && e.content.is_none())).count(); |
| 335 | 0× | if left == 0 && written.is_empty() { |
| 336 | 0× | return Err(bad("A drop keeps at least one file; delete the drop instead.")); |
| 337 | 0× | } |
| 338 | 2× | } |
| 339 | 2× | let b = row.default_bookmark.clone(); |
| 340 | 2× | let done = edits::commit(&app.repos, &name, b.clone(), b, u.files, message, signer) |
| 341 | 2× | .await |
| 342 | 2× | .map_err(|e| DropError::Bad(format!("The drop was not changed: {e}")))?; |
| 343 | 2× | recorded(app, account, row, &done, "drop.edited", "").await; |
| 344 | 0× | } |
| 345 | 2× | db::repos::set_drop(&app.db, row.id, title, description, visibility(u.secret)).await?; |
| 346 | 2× | if visibility(u.secret) != row.visibility() { |
| 347 | 1× | db::accounts::audit(&app.db, Some(account), "drop.visibility", &row.slug(), if u.secret { "secret" } else { "public" }).await.ok(); |
| 348 | 1× | } |
| 349 | 2× | Ok(()) |
| 350 | 3× | } |
| 351 | |
|
| 352 | |
|
| 353 | 7× | async fn recorded(app: &Shared, account: &Account, row: &RepoRow, done: &edits::Committed, action: &str, detail: &str) { |
| 354 | 7× | let outcome = db::repos::PushOutcome { |
| 355 | 7× | reference: done.bookmark.clone(), |
| 356 | 7× | ok: true, |
| 357 | 7× | reason: "web edit".into(), |
| 358 | 7× | old_state: done.old_state.clone(), |
| 359 | 7× | new_state: done.new_state.clone(), |
| 360 | 7× | heads: done.heads.len() as i64, |
| 361 | 7× | changes: 1, |
| 362 | 7× | }; |
| 363 | 7× | db::repos::record_push(&app.db, row.id, Some((account.id, &account.username)), &[outcome]).await.ok(); |
| 364 | 7× | let detail = if detail.is_empty() { done.change.short(12) } else { format!("{detail} {}", done.change.short(12)) }; |
| 365 | 7× | db::accounts::audit(&app.db, Some(account), action, &row.slug(), &detail).await.ok(); |
| 366 | 7× | } |
| 367 | |
|
| 368 | |
|
| 369 | |
|
| 370 | 4× | pub async fn delete(app: &Shared, actor: &Account, row: &RepoRow) -> Result<(), DropError> { |
| 371 | 4× | let name = RepoName::new(&row.owner, &row.name).ok_or_else(|| bad("not a drop"))?; |
| 372 | 4× | let _guard = app.repos.exclusive_guard(&name).await; |
| 373 | 4× | match app.repos.trash(&name) { |
| 374 | 4× | Ok(_) | Err(RepoError::NotFound) => {} |
| 375 | 0× | Err(e) => return Err(e.into()), |
| 376 | | } |
| 377 | 4× | db::repos::delete(&app.db, row.id).await?; |
| 378 | 4× | db::accounts::audit(&app.db, Some(actor), "drop.deleted", &row.slug(), "moved to trash").await.ok(); |
| 379 | 4× | Ok(()) |
| 380 | 4× | } |
| 381 | |
|
| 382 | |
|
| 383 | |
|
| 384 | 50× | pub async fn files(app: &Shared, row: &RepoRow, spec: &str) -> Result<(crate::repos::view::RefTarget, Vec<DropFile>), RepoError> { |
| 385 | 50× | let name = RepoName::new(&row.owner, &row.name).ok_or(RepoError::NotFound)?; |
| 386 | 50× | let spec = if spec.is_empty() { row.default_bookmark.clone() } else { spec.to_string() }; |
| 387 | 50× | app.repos |
| 388 | 50× | .read(&name, move |repo, cache| { |
| 389 | 50× | let target = crate::repos::view::resolve(repo, cache, &spec)?; |
| 390 | 50× | let loaded = cache.load(repo, &target.heads)?; |
| 391 | 50× | let mut out = Vec::new(); |
| 392 | 77× | for (path, f) in &loaded.snapshot.files { |
| 393 | 77× | let bytes = match (&f.kind, &f.content) { |
| 394 | 0× | (NodeKind::Dir, _) | (_, FileContent::Dir) => continue, |
| 395 | 77× | (_, FileContent::Text { bytes, .. }) => bytes.clone(), |
| 396 | 0× | (_, FileContent::Opaque { blobs }) => match blobs.first() { |
| 397 | 0× | Some(b) => repo.read_blob(b)?, |
| 398 | 0× | None => Vec::new(), |
| 399 | | }, |
| 400 | | }; |
| 401 | 77× | let size = bytes.len() as u64; |
| 402 | 77× | let text = if size <= SHOWN_BYTES && !crate::repos::view::is_binary(&bytes) { String::from_utf8(bytes).ok() } else { None }; |
| 403 | 77× | out.push(DropFile { path: path.clone(), size, text }); |
| 404 | | } |
| 405 | 50× | Ok((target, out)) |
| 406 | 50× | }) |
| 407 | 50× | .await |
| 408 | 50× | } |
| 409 | |
|
| 410 | |
|
| 411 | 56× | pub async fn find(app: &Shared, owner: &str, id: &str) -> sqlx::Result<Option<RepoRow>> { |
| 412 | 56× | let Some(name) = RepoName::drop(owner, id) else { return Ok(None) }; |
| 413 | 56× | Ok(db::repos::get(&app.db, &name.owner, &name.name).await?.filter(|r| r.is_drop())) |
| 414 | 56× | } |
| 415 | |
|
| 416 | |
|
| 417 | 78× | pub fn may_edit(account: Option<&Account>, row: &RepoRow) -> bool { |
| 418 | 78× | account.is_some_and(|a| !a.blocked() && a.username.eq_ignore_ascii_case(&row.owner)) |
| 419 | 78× | } |
| 420 | |
|
| 421 | |
|
| 422 | |
|
| 423 | 40× | pub fn may_delete(account: Option<&Account>, row: &RepoRow) -> bool { |
| 424 | 40× | may_edit(account, row) || account.is_some_and(|a| !a.blocked() && a.is_admin) |
| 425 | 40× | } |
| 426 | |
|
| 427 | |
|
| 428 | |
|
| 429 | 14× | pub fn listed_to(account: Option<&Account>, row: &RepoRow) -> bool { |
| 430 | 14× | row.visibility() == Visibility::Public || may_delete(account, row) |
| 431 | 14× | } |
| 432 | |
|
| 433 | |
|
| 434 | |
|
| 435 | |
|
| 436 | 1× | pub fn edits_between(current: &[DropFile], wanted: &[NewFile]) -> Vec<FileEdit> { |
| 437 | 1× | let mut out = Vec::new(); |
| 438 | 2× | for c in current.iter().filter(|c| c.text.is_some()) { |
| 439 | 3× | if !wanted.iter().any(|w| w.name == c.path) { |
| 440 | 0× | out.push(FileEdit { path: c.path.clone(), content: None }); |
| 441 | 2× | } |
| 442 | | } |
| 443 | 2× | for w in wanted { |
| 444 | 3× | let same = current.iter().any(|c| c.path == w.name && c.text.as_deref() == Some(w.content.as_str())); |
| 445 | 2× | if !same { |
| 446 | 1× | out.push(FileEdit { path: w.name.clone(), content: Some(w.content.clone().into_bytes()) }); |
| 447 | 1× | } |
| 448 | | } |
| 449 | 1× | out |
| 450 | 1× | } |