This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

knot2/git: isolate repo open so ambient config can't inject archive filter driver

Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>

author did:plc:3fwecdnvtcscjnrx2p4n7a… date (Jul 29, 2026, 5:13 PM +0300) commit d10a0d2d parent 80deba14 change-id rtyntoyt
+227 -28
+6
knot2/crates/knot-fixtures/src/lib.rs
··· 96 96 must(work, &["commit", "-q", "-m", message]); 97 97 } 98 98 99 + pub fn contains(haystack: &[u8], needle: &[u8]) -> bool { 100 + haystack 101 + .windows(needle.len()) 102 + .any(|window| window == needle) 103 + } 104 + 99 105 #[cfg(test)] 100 106 mod tests { 101 107 use super::*;
+104 -8
knot2/crates/knot-git/src/repo.rs
··· 245 245 } 246 246 } 247 247 248 + // `worktree_stream` builds a `gix_filter::Pipeline` 249 + // out of whatever config it has loaded, 250 + // so a filter command defined in any of those sources 251 + // runs against the tree being archived. 252 + // The repository's own config is then the only source gix reads, 253 + // and mr knot wrote that file when it created the repo under the scan path. 254 + // We pin the trust because gix otherwise works it out from who owns the git dir, 255 + // and it reduces the trust when someone else owns that dir, 256 + // at which point it applies a 16MiB object allocation limit 257 + // and treats the repository's own config sections as untrusted. 258 + // Under isolation no safe.directory entry can restore `Full` trust, 259 + // since gix honors that key from only system/global config. 260 + fn isolated_open_options() -> gix::open::Options { 261 + gix::open::Options::isolated().with(gix::sec::Trust::Full) 262 + } 263 + 248 264 pub(crate) fn init_bare_with_format( 249 265 path: &Path, 250 266 format: ObjectFormat, ··· 257 273 object_hash, 258 274 ..Default::default() 259 275 }, 260 - gix::open::Options::default(), 276 + isolated_open_options(), 261 277 ) 262 278 .map(Into::into) 263 279 .map_err(|error| error.to_string()) ··· 270 286 } 271 287 272 288 fn init_bare_idempotent(path: PathBuf) -> Result<Repo, GitError> { 273 - if let Ok(git) = gix::open(&path) { 289 + if let Ok(git) = gix::open_opts(&path, isolated_open_options()) { 274 290 return Ok(assembled(git, path)); 275 291 } 276 292 let parent = path.parent().ok_or_else(|| GitError::Create { ··· 283 299 })?; 284 300 let staging = staging_path(parent); 285 301 let _ = std::fs::remove_dir_all(&staging); 286 - gix::init_bare(&staging).map_err(|error| GitError::Create { 302 + init_bare_with_format(&staging, ObjectFormat::SHA1).map_err(|message| GitError::Create { 287 303 path: staging.clone(), 288 - message: error.to_string(), 304 + message, 289 305 })?; 290 306 match std::fs::rename(&staging, &path) { 291 307 Ok(()) => Repo::open(path), ··· 442 458 impl Repo { 443 459 pub fn open(path: impl Into<PathBuf>) -> Result<Repo, GitError> { 444 460 let path = path.into(); 445 - let git = gix::open(&path).map_err(|error| GitError::Open { 446 - path: path.clone(), 447 - message: error.to_string(), 448 - })?; 461 + let git = 462 + gix::open_opts(&path, isolated_open_options()).map_err(|error| GitError::Open { 463 + path: path.clone(), 464 + message: error.to_string(), 465 + })?; 449 466 Ok(assembled(git, path)) 450 467 } 451 468 ··· 1084 1101 let layout = Layout::new(dir.path()); 1085 1102 let did = RepoDid::new("did:plc:squid").unwrap(); 1086 1103 (dir, layout, did) 1104 + } 1105 + 1106 + #[test] 1107 + fn every_repo_opens_against_its_own_config_and_nothing_ambient() { 1108 + let permissions = isolated_open_options().permissions; 1109 + let config = permissions.config; 1110 + assert!( 1111 + !config.system 1112 + && !config.git 1113 + && !config.user 1114 + && !config.env 1115 + && !config.includes 1116 + && !config.git_binary, 1117 + "a filter driver in ambient config would execute when worktree_stream archives a \ 1118 + pushed tree, so we read only the repository's own config: {config:?}" 1119 + ); 1120 + assert!( 1121 + !permissions.attributes.system 1122 + && !permissions.attributes.git 1123 + && !permissions.attributes.git_binary, 1124 + "only the archived tree's own .gitattributes may set filter= on a path: {:?}", 1125 + permissions.attributes 1126 + ); 1127 + let denied = |permission| matches!(permission, gix::sec::Permission::Deny); 1128 + let env = permissions.env; 1129 + assert!( 1130 + denied(env.xdg_config_home) 1131 + && denied(env.home) 1132 + && denied(env.git_prefix) 1133 + && denied(env.ssh_prefix) 1134 + && denied(env.identity) 1135 + && denied(env.objects) 1136 + && denied(env.http_transport), 1137 + "gix resolves GIT_CONFIG_KEY_n, HOME and XDG_CONFIG_HOME into config that can define a \ 1138 + filter driver: {env:?}" 1139 + ); 1140 + assert!( 1141 + permissions.is_isolated(), 1142 + "every permission must match the set gix itself calls isolated, including any field a \ 1143 + gix upgrade adds that the three checks above don't name: {permissions:?}" 1144 + ); 1145 + } 1146 + 1147 + #[cfg(unix)] 1148 + #[test] 1149 + fn the_knot_keeps_full_trust_on_a_git_dir_it_no_longer_owns() { 1150 + const NOBODY: u32 = 65534; 1151 + let (_dir, layout, did) = repo(); 1152 + layout.create(&did).unwrap(); 1153 + let path = layout.repo_path(&did).unwrap(); 1154 + let unreachable_uid = |kind| { 1155 + matches!( 1156 + kind, 1157 + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::InvalidInput 1158 + ) 1159 + }; 1160 + match std::os::unix::fs::chown(&path, Some(NOBODY), None) { 1161 + Err(error) if unreachable_uid(error.kind()) => { 1162 + eprintln!( 1163 + "skipping the foreign-owner trust check: chown to {NOBODY} needs root and a \ 1164 + uid mapping reaching that far" 1165 + ); 1166 + return; 1167 + } 1168 + outcome => outcome.unwrap(), 1169 + } 1170 + assert_eq!( 1171 + layout.open(&did).unwrap().git().git_dir_trust(), 1172 + gix::sec::Trust::Full, 1173 + "reduced trust applies a 16MiB limit to every object allocation" 1174 + ); 1175 + assert_eq!( 1176 + gix::open_opts(&path, gix::open::Options::isolated()) 1177 + .unwrap() 1178 + .git_dir_trust(), 1179 + gix::sec::Trust::Reduced, 1180 + "gix raised the trust of a git dir owned by another user with no safe.directory entry \ 1181 + in reach" 1182 + ); 1087 1183 } 1088 1184 1089 1185 #[test]
+1 -1
knot2/crates/knot-git/tests/common/mod.rs
··· 4 4 use knot_types::RepoDid; 5 5 6 6 pub use knot_fixtures::{ 7 - available as git_available, commit as commit_file, must as git_ok, run as git, 7 + available as git_available, commit as commit_file, contains, must as git_ok, run as git, 8 8 }; 9 9 10 10 pub fn seeded() -> (tempfile::TempDir, tempfile::TempDir, Layout, RepoDid) {
+102
knot2/crates/knot-git/tests/config_isolation.rs
··· 1 + use std::path::Path; 2 + use std::sync::atomic::AtomicBool; 3 + 4 + use knot_git::ArchiveFormat; 5 + use knot_types::Oid; 6 + 7 + mod common; 8 + use common::{commit_file, contains, git_ok as git, seeded}; 9 + 10 + fn archive_without_isolation(bare_path: &Path, head: Oid) -> Vec<u8> { 11 + let permissive = gix::open_opts(bare_path, gix::open::Options::default()).unwrap(); 12 + let tree = permissive 13 + .find_object(head.object_id()) 14 + .unwrap() 15 + .peel_to_tree() 16 + .unwrap(); 17 + let (stream, _index) = permissive.worktree_stream(tree.id).unwrap(); 18 + let mut out = std::io::Cursor::new(Vec::new()); 19 + permissive 20 + .worktree_archive( 21 + stream, 22 + &mut out, 23 + gix::progress::Discard, 24 + &AtomicBool::new(false), 25 + gix_archive::Options { 26 + format: gix_archive::Format::Tar, 27 + tree_prefix: None, 28 + modification_time: 0, 29 + }, 30 + ) 31 + .unwrap(); 32 + out.into_inner() 33 + } 34 + 35 + #[test] 36 + fn a_filter_driver_pulled_in_by_an_include_never_runs_for_a_served_archive() { 37 + let (_scan, work_dir, layout, did) = seeded(); 38 + let work = work_dir.path(); 39 + let bare_path = layout.repo_path(&did).unwrap(); 40 + std::fs::write(work.join(".gitattributes"), "payload.txt filter=knotpwn\n").unwrap(); 41 + commit_file(work, "payload.txt", "kelp\n", "seed"); 42 + git(work, &["push", "-q", bare_path.to_str().unwrap(), "main"]); 43 + let head = Oid::from_hex(&git(work, &["rev-parse", "HEAD"])).unwrap(); 44 + 45 + let ambient = tempfile::tempdir().unwrap(); 46 + let driver = ambient.path().join("driver.cfg"); 47 + std::fs::write( 48 + &driver, 49 + "[filter \"knotpwn\"]\n\tsmudge = sed s/kelp/pwned/\n\trequired = true\n", 50 + ) 51 + .unwrap(); 52 + let config_path = bare_path.join("config"); 53 + let local = std::fs::read_to_string(&config_path).unwrap(); 54 + std::fs::write( 55 + &config_path, 56 + format!("{local}[include]\n\tpath = {}\n", driver.display()), 57 + ) 58 + .unwrap(); 59 + 60 + let via_git = knot_fixtures::command(&bare_path) 61 + .args(["archive", "--format=tar", "main"]) 62 + .output() 63 + .unwrap(); 64 + assert!( 65 + via_git.status.success(), 66 + "git archive failed:\n{}", 67 + String::from_utf8_lossy(&via_git.stderr) 68 + ); 69 + assert!( 70 + contains(&via_git.stdout, b"pwned"), 71 + "git archived with the include in place and its output has no pwned in it, so the driver \ 72 + never ran" 73 + ); 74 + assert!( 75 + contains(&archive_without_isolation(&bare_path, head), b"pwned"), 76 + "gix archived the same repo under default permissions and its output has no pwned in it, \ 77 + so worktree_stream no longer applies filters" 78 + ); 79 + 80 + let bare = layout.open(&did).unwrap(); 81 + let tree = bare.peel_to_tree(head).unwrap(); 82 + let mut out = std::io::Cursor::new(Vec::new()); 83 + bare.write_archive(tree, ArchiveFormat::Tar, None, &mut out) 84 + .unwrap(); 85 + let served = out.into_inner(); 86 + 87 + assert!( 88 + std::fs::read_to_string(&config_path) 89 + .unwrap() 90 + .contains("[include]"), 91 + "opening the repo rewrote its config and removed the include, so gix never read the \ 92 + driver definition for the archive below" 93 + ); 94 + assert!( 95 + contains(&served, b"kelp"), 96 + "the served archive contains the blob as it was pushed" 97 + ); 98 + assert!( 99 + !contains(&served, b"pwned"), 100 + "the knot ran a filter driver defined by config outside the repository" 101 + ); 102 + }
+2 -3
knot2/crates/knot-git/tests/reads.rs
··· 8 8 } 9 9 10 10 mod common; 11 - use common::{commit_file, git_ok as git}; 11 + use common::{commit_file, contains, git_ok as git}; 12 12 13 13 #[test] 14 14 fn typed_reads_over_a_seeded_repo() { ··· 641 641 let mut decoder = flate2::read::GzDecoder::new(compressed.as_slice()); 642 642 let mut tar = Vec::new(); 643 643 std::io::Read::read_to_end(&mut decoder, &mut tar).unwrap(); 644 - let needle = b"squid-main/src/lib.rs"; 645 644 assert!( 646 - tar.windows(needle.len()).any(|window| window == needle), 645 + contains(&tar, b"squid-main/src/lib.rs"), 647 646 "tar contains prefixed entries" 648 647 ); 649 648 }
+1 -1
knot2/crates/knot-pack/tests/common/mod.rs
··· 12 12 use knot_pack::{RepoLookup, RepoResolver, RepoTarget}; 13 13 use knot_types::{ObjectFormat, RepoDid}; 14 14 15 - pub use knot_fixtures::{commit, must, run as git}; 15 + pub use knot_fixtures::{commit, contains, must, run as git}; 16 16 17 17 pub fn pkt(payload: &[u8]) -> Vec<u8> { 18 18 let mut out = format!("{:04x}", payload.len() + 4).into_bytes();
+4 -6
knot2/crates/knot-pack/tests/git_client.rs
··· 9 9 use knot_types::{OwnerDid, RefName, RepoDid, RepoRkey}; 10 10 11 11 mod common; 12 - use common::{commit, git, must, pkt, serve_dids, spawn, unsideband}; 12 + use common::{commit, contains, git, must, pkt, serve_dids, spawn, unsideband}; 13 13 14 14 fn seed_repo(work: &Path, bare: &str, file: &str, contents: &str) { 15 15 std::fs::create_dir_all(work).unwrap(); ··· 471 471 "archive response opens with the ACK pkt-line" 472 472 ); 473 473 assert!( 474 - body.windows("README.md".len()) 475 - .any(|window| window == b"README.md"), 474 + contains(&body, b"README.md"), 476 475 "framed archive contains README.md entry" 477 476 ); 478 477 ··· 523 522 "archiving cob-only tree must be refused" 524 523 ); 525 524 assert!( 526 - !cob.windows("README.md".len()) 527 - .any(|window| window == b"README.md"), 525 + !contains(&cob, b"README.md"), 528 526 "refused archive mustn't leak the hidden tree's contents" 529 527 ); 530 528 } ··· 834 832 ); 835 833 let collected = streamed.into_body().collect().await.unwrap().to_bytes(); 836 834 assert!( 837 - collected.windows(4).any(|window| window == b"PACK"), 835 + contains(&collected, b"PACK"), 838 836 "streamed response must contain a real PACK" 839 837 ); 840 838 }
+2 -4
knot2/crates/knot-pack/tests/h3_conformance.rs
··· 23 23 use tokio_util::sync::CancellationToken; 24 24 25 25 mod common; 26 - use common::{must, pack_objects, receive_request}; 26 + use common::{contains, must, pack_objects, receive_request}; 27 27 28 28 type Captured = (Method, Uri, HeaderMap, Bytes); 29 29 ··· 373 373 .find(|(method, uri, _, body)| { 374 374 method == Method::POST 375 375 && uri.path().ends_with("/git-upload-pack") 376 - && body 377 - .windows(b"command=fetch".len()) 378 - .any(|window| window == b"command=fetch") 376 + && contains(body, b"command=fetch") 379 377 }) 380 378 .cloned() 381 379 .unwrap_or_else(|| {
+4 -4
knot2/crates/knot-pack/tests/hardening.rs
··· 7 7 8 8 mod common; 9 9 use common::{ 10 - commit, delta_bomb_pack, index_into_bare, must, pack_objects, pack_objects_tuned, pkt, 11 - receive_request, seeded, unsideband, 10 + commit, contains, delta_bomb_pack, index_into_bare, must, pack_objects, pack_objects_tuned, 11 + pkt, receive_request, seeded, unsideband, 12 12 }; 13 13 14 14 fn generous() -> PackLimits { ··· 679 679 "must open packfile section after ready" 680 680 ); 681 681 assert!( 682 - bytes.windows(4).any(|window| window == b"PACK"), 682 + contains(&bytes, b"PACK"), 683 683 "side-band payload must contain a real PACK" 684 684 ); 685 685 ··· 714 714 "once done arrives server opens the pack:\n{finished_text}" 715 715 ); 716 716 assert!( 717 - finished.windows(4).any(|window| window == b"PACK"), 717 + contains(&finished, b"PACK"), 718 718 "follow-up round must contain a real PACK" 719 719 ); 720 720
+1 -1
knot2/crates/knot-ssh/tests/ssh_push.rs
··· 1360 1360 1361 1361 let tar = std::fs::read(&out_tar).unwrap(); 1362 1362 assert!( 1363 - tar.windows(b"README.md".len()).any(|w| w == b"README.md"), 1363 + knot_fixtures::contains(&tar, b"README.md"), 1364 1364 "archived tar must contain the README.md entry" 1365 1365 ); 1366 1366 }