use super::super::release::Artifact; use super::*; use sha2::{Digest, Sha256}; fn manifest() -> ReleaseManifest { ReleaseManifest::new( Version::parse("1.2.3").unwrap(), vec![Artifact { path: "dever-core".into(), bytes: 4, sha256: super::super::release::hex(&Sha256::digest(b"core")), }], ) } fn archive(entries: &[(&str, &[u8])]) -> Vec { let mut archive = tar::Builder::new(Vec::new()); for (name, contents) in entries { let mut header = tar::Header::new_ustar(); header.set_size(contents.len() as u64); header.set_mode(0o644); header.set_cksum(); archive.append_data(&mut header, name, *contents).unwrap(); } archive.into_inner().unwrap() } #[test] fn release_archive_accepts_only_the_exact_signed_regular_files() { let root = Download::new(&std::env::temp_dir()).unwrap(); unpack( archive(&[("dever-core", b"core")]).as_slice(), &root.0, &manifest(), ) .unwrap(); assert_eq!(fs::read(root.0.join("dever-core")).unwrap(), b"core"); for entries in [ vec![ ("dever-core", b"core".as_slice()), ("extra", b"x".as_slice()), ], vec![ ("dever-core", b"core".as_slice()), ("dever-core", b"core".as_slice()), ], vec![("dever-core", b"bad".as_slice())], vec![], ] { let root = Download::new(&std::env::temp_dir()).unwrap(); assert!(unpack(archive(&entries).as_slice(), &root.0, &manifest()).is_err()); } } #[test] fn release_archive_rejects_links_and_extension_headers() { for kind in [ tar::EntryType::Symlink, tar::EntryType::Link, tar::EntryType::XGlobalHeader, ] { let root = Download::new(&std::env::temp_dir()).unwrap(); let mut archive = tar::Builder::new(Vec::new()); let mut header = tar::Header::new_ustar(); header.set_path("dever-core").unwrap(); header.set_entry_type(kind); header.set_size(0); header.set_mode(0o644); header.set_link_name("/outside").unwrap(); header.set_cksum(); archive.append(&header, io::empty()).unwrap(); assert!( unpack( archive.into_inner().unwrap().as_slice(), &root.0, &manifest() ) .is_err() ); assert!(!root.0.join("dever-core").exists()); } } #[test] fn signed_catalog_paths_and_total_size_are_bounded_before_extraction() { for path in [ "/absolute", "../outside", "a/../b", "./file", "a//b", "a\\b", "C:drive", "manifest.json", "manifest.sig", ] { let mut manifest = manifest(); manifest.artifacts[0].path = path.into(); assert!(validate_catalog(&manifest).is_err(), "{path}"); } let mut excessive = manifest(); excessive.artifacts[0].bytes = RELEASE_LIMIT + 1; assert!(validate_catalog(&excessive).is_err()); let mut duplicate = manifest(); duplicate.artifacts.push(duplicate.artifacts[0].clone()); assert!(validate_catalog(&duplicate).is_err()); } #[test] fn official_redirects_reject_http_credentials_ports_and_foreign_hosts() { let current = "https://git.dever.cc/attachments/a"; let origin = url::Url::parse(OFFICIAL_RELEASES).unwrap(); assert!( redirect( current, "https://git.dever.cc/attachments/b?token=opaque", &origin, ) .is_ok() ); assert!(redirect(current, "/attachments/b", &origin).is_ok()); for address in [ "http://git.dever.cc/a", "https://evil.example/a", "https://git.dever.cc.evil.example/a", "https://user@git.dever.cc/a", "https://git.dever.cc:444/a", "https://git.dever.cc/a#fragment", "https://github.com/a", "https://release-assets.githubusercontent.com/a", ] { assert!(redirect(current, address, &origin).is_err(), "{address}"); assert!( validate_download_url(address, &origin).is_err(), "{address}" ); } assert!(validate_download_url("/attachments/b", &origin).is_err()); } #[test] fn official_assets_use_exact_platform_names_and_return_the_published_url() { let suffix = "ext-runtime-pip-linux-x86_64.tar.zst"; let metadata = release_metadata("https://git.dever.cc", "v1.2.3", &[(suffix, b"archive")]); let release = ResolvedRelease::parse(&metadata, "latest").unwrap(); assert_eq!(release.version.as_str(), "1.2.3"); assert_eq!( release.asset(suffix).unwrap().browser_download_url, "https://git.dever.cc/attachments/0" ); assert!(release.asset("manifest.json").is_err()); assert!( release .asset("ext-runtime-pip-linux-aarch64.tar.zst") .is_err() ); } #[test] fn release_metadata_rejects_unstable_tags_mismatched_versions_and_malformed_assets() { let good: serde_json::Value = serde_json::from_slice(&release_metadata( "https://git.dever.cc", "v1.2.3", &[("tar.zst", b"archive")], )) .unwrap(); for tag in [ "1.2.3", "v01.2.3", "v1.2", "v1.2.3-rc.1", "v1.2.3+build", "v../escape", ] { let mut metadata = good.clone(); metadata["tag_name"] = tag.into(); assert!( ResolvedRelease::parse(&serde_json::to_vec(&metadata).unwrap(), "latest").is_err(), "{tag}" ); } assert!(ResolvedRelease::parse(&serde_json::to_vec(&good).unwrap(), "1.2.4").is_err()); for field in ["draft", "prerelease"] { let mut metadata = good.clone(); metadata[field] = true.into(); assert!(ResolvedRelease::parse(&serde_json::to_vec(&metadata).unwrap(), "latest").is_err()); metadata.as_object_mut().unwrap().remove(field); assert!(ResolvedRelease::parse(&serde_json::to_vec(&metadata).unwrap(), "latest").is_err()); } let asset = good["assets"][0].clone(); for assets in [ serde_json::json!([]), serde_json::json!([asset, asset]), serde_json::json!(vec![asset.clone(); ASSET_LIMIT + 1]), ] { let mut metadata = good.clone(); metadata["assets"] = assets; assert!(ResolvedRelease::parse(&serde_json::to_vec(&metadata).unwrap(), "latest").is_err()); } for size in [ serde_json::json!(0), serde_json::json!(-1), serde_json::json!(1.5), serde_json::json!(RELEASE_LIMIT + 1), serde_json::json!("7"), ] { let mut metadata = good.clone(); metadata["assets"][0]["size"] = size; assert!(ResolvedRelease::parse(&serde_json::to_vec(&metadata).unwrap(), "latest").is_err()); } for name in ["", "../asset", "a/b", "a\\b"] { let mut metadata = good.clone(); metadata["assets"][0]["name"] = name.into(); assert!(ResolvedRelease::parse(&serde_json::to_vec(&metadata).unwrap(), "latest").is_err()); } } #[test] fn release_asset_reader_requires_the_exact_declared_size() { for size in [2, 4] { let mut reader = AssetReader { reader: io::Cursor::new(b"abc"), remaining: size, }; assert!(reader.read_to_end(&mut Vec::new()).is_err()); } let mut reader = AssetReader { reader: io::Cursor::new(b"abc"), remaining: 3, }; assert_eq!(reader.read(&mut []).unwrap(), 0); let mut bytes = Vec::new(); reader.read_to_end(&mut bytes).unwrap(); assert_eq!(bytes, b"abc"); } fn signed_release() -> (ReleaseManifest, Vec, Vec, Vec) { use ring::signature::{Ed25519KeyPair, KeyPair}; let pair = Ed25519KeyPair::from_seed_unchecked(&[37; 32]).unwrap(); let mut manifest = manifest(); manifest.artifacts.push(Artifact { path: "skills/dever-language/SKILL.md".into(), bytes: 5, sha256: super::super::release::hex(&Sha256::digest(b"skill")), }); let bytes = serde_json::to_vec(&manifest).unwrap(); let signature = super::super::release::hex(pair.sign(&bytes).as_ref()).into_bytes(); ( manifest, bytes, signature, pair.public_key().as_ref().to_vec(), ) } fn server(responses: Vec>) -> (ReleaseSource, std::thread::JoinHandle<()>) { release_server("latest", "v1.2.3", responses) } fn release_metadata(base: &str, tag: &str, assets: &[(&str, &[u8])]) -> Vec { serde_json::to_vec(&serde_json::json!({ "tag_name": tag, "draft": false, "prerelease": false, "assets": assets.iter().enumerate().map(|(index, (suffix, bytes))| serde_json::json!({ "name": format!("dever-{}.{suffix}", platform_identity()), "browser_download_url": format!("{base}/attachments/{index}"), "size": bytes.len(), })).collect::>(), })) .unwrap() } fn release_server( selection: &str, tag: &str, responses: Vec>, ) -> (ReleaseSource, std::thread::JoinHandle<()>) { serve(|base| { let assets: Vec<_> = ["manifest.json", "manifest.sig", "tar.zst"] .into_iter() .zip(&responses) .map(|(suffix, bytes)| (suffix, bytes.as_slice())) .collect(); let metadata = release_metadata(base, tag, &assets); std::iter::once((format!("/releases/{selection}"), metadata)) .chain( responses .into_iter() .enumerate() .map(|(index, bytes)| (format!("/attachments/{index}"), bytes)), ) .collect() }) } fn serve( responses: impl FnOnce(&str) -> Vec<(String, Vec)>, ) -> (ReleaseSource, std::thread::JoinHandle<()>) { use std::io::BufRead; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); let responses = responses(&base); let worker = std::thread::spawn(move || { let deadline = Instant::now() + Duration::from_secs(5); for (path, response) in responses { let mut stream = loop { match listener.accept() { Ok((stream, _)) => break stream, Err(error) if error.kind() == io::ErrorKind::WouldBlock => { assert!( Instant::now() < deadline, "expected release request did not arrive" ); std::thread::sleep(Duration::from_millis(5)); } Err(error) => panic!("{error}"), } }; stream .set_read_timeout(Some(Duration::from_secs(2))) .unwrap(); let mut reader = io::BufReader::new(stream.try_clone().unwrap()); let mut request = String::new(); reader.read_line(&mut request).unwrap(); assert_eq!(request, format!("GET {path} HTTP/1.1\r\n")); loop { let mut line = String::new(); reader.read_line(&mut line).unwrap(); if line == "\r\n" { break; } } write!( stream, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", response.len() ) .unwrap(); stream.write_all(&response).unwrap(); } }); (ReleaseSource::new(format!("{base}/releases")), worker) } #[test] fn latest_and_extensions_share_one_release_snapshot() { use super::super::{BuildTarget, ExtensionId, ExtensionKind, ExtensionSource}; use crate::libs::Ecosystem; let extension = ExtensionId { kind: ExtensionKind::Runtime(Ecosystem::Pip), target: BuildTarget::LinuxX86_64, }; let (source, worker) = serve(|base| { vec![ ( "/releases/latest".into(), release_metadata(base, "v1.2.3", &[(&extension.asset_suffix(), b"extension")]), ), ("/attachments/0".into(), b"extension".to_vec()), ("/attachments/0".into(), b"extension".to_vec()), ] }); let selected = source.resolve("latest", Instant::now()).unwrap(); assert!(Rc::ptr_eq( &selected, &source.resolve("latest", Instant::now()).unwrap() )); assert!(Rc::ptr_eq( &selected, &source.resolve("1.2.3", Instant::now()).unwrap() )); for _ in 0..2 { let mut contents = Vec::new(); source .open(&selected.version, &extension) .unwrap() .read_to_end(&mut contents) .unwrap(); assert_eq!(contents, b"extension"); } worker.join().unwrap(); } #[test] fn exact_versions_resolve_the_tag_endpoint_and_missing_or_foreign_assets_fail() { let (source, worker) = serve(|base| { vec![( "/releases/tags/v1.2.3".into(), release_metadata(base, "v1.2.3", &[("tar.zst", b"archive")]), )] }); let selected = source.resolve("1.2.3", Instant::now()).unwrap(); worker.join().unwrap(); assert!(selected.asset("manifest.sig").is_err()); let foreign = ReleaseAsset { name: "asset".into(), browser_download_url: "https://evil.example/archive".into(), size: 7, }; assert!( source .open_asset(&foreign, Instant::now()) .err() .unwrap() .contains("origin") ); for version in ["../latest", "v1.2.3", "1.2.3-rc.1"] { assert!(source.resolve(version, Instant::now()).is_err()); } let (source, worker) = serve(|_| { vec![( "/releases/latest".into(), release_metadata("https://evil.example", "v1.2.3", &[("tar.zst", b"archive")]), )] }); assert!( source .resolve("latest", Instant::now()) .err() .unwrap() .contains("origin") ); worker.join().unwrap(); } #[test] fn release_metadata_downloads_are_bounded_and_asset_lengths_are_checked() { let (source, worker) = serve(|_| { vec![( "/releases/latest".into(), vec![b' '; METADATA_LIMIT as usize + 1], )] }); assert!( source .resolve("latest", Instant::now()) .err() .unwrap() .contains("size limit") ); worker.join().unwrap(); let root = Download::new(&std::env::temp_dir()).unwrap(); for size in [2, 4] { let (source, worker) = serve(|_| vec![("/attachments/manifest".into(), b"abc".to_vec())]); let asset = ReleaseAsset { name: "asset".into(), browser_download_url: source.base.join("/attachments/manifest").unwrap().into(), size, }; assert!( source .metadata(&asset, &root.0.join("manifest.json"), Instant::now()) .unwrap_err() .contains("size") ); worker.join().unwrap(); assert!(!root.0.join("manifest.json").exists()); } } fn compressed_release(core: &[u8]) -> Vec { let bytes = archive(&[ ("dever-core", core), ("skills/dever-language/SKILL.md", b"skill"), ]); compressed(&bytes) } fn compressed(bytes: &[u8]) -> Vec { let mut encoder = zstd::stream::write::Encoder::new(Vec::new(), 1).unwrap(); encoder.include_checksum(true).unwrap(); encoder.write_all(bytes).unwrap(); encoder.finish().unwrap() } #[test] fn zstandard_frames_require_checksum_bounded_window_and_exact_eof() { let tar = archive(&[("dever-core", b"core")]); let good = compressed(&tar); let root = Download::new(&std::env::temp_dir()).unwrap(); super::super::release_archive::extract(good.as_slice(), &root.0, &manifest().artifacts) .unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; assert_eq!( fs::metadata(root.0.join("dever-core")) .unwrap() .permissions() .mode() & 0o777, 0o755, "signed executable roles retain execution after extraction" ); } let mut bad_checksum = good.clone(); *bad_checksum.last_mut().unwrap() ^= 1; let mut concatenated = good.clone(); concatenated.extend(&good); let mut garbage = good.clone(); garbage.push(0); let oversized_window = vec![ 0x28, 0xb5, 0x2f, 0xfd, 4, (28 - 10) << 3, 1, 0, 0, 0, 0, 0, 0, ]; for bytes in [ bad_checksum, concatenated, garbage, oversized_window, good[..good.len() - 1].to_vec(), zstd::stream::encode_all(tar.as_slice(), 1).unwrap(), ] { let root = Download::new(&std::env::temp_dir()).unwrap(); assert!( super::super::release_archive::extract( bytes.as_slice(), &root.0, &manifest().artifacts ) .is_err() ); } } #[test] fn tar_trailers_and_same_sized_corruption_are_rejected() { let tar = archive(&[("dever-core", b"core")]); let mut nonzero_tail = tar.clone(); nonzero_tail.push(1); let mut nonzero_padding = tar.clone(); nonzero_padding[516] = 1; for bytes in [ nonzero_tail, nonzero_padding, tar[..tar.len() - 512].to_vec(), archive(&[("dever-core", b"evil")]), ] { let root = Download::new(&std::env::temp_dir()).unwrap(); assert!( super::super::release_archive::extract( compressed(&bytes).as_slice(), &root.0, &manifest().artifacts ) .is_err() ); } } #[test] fn signed_extensions_cannot_duplicate_or_escape_their_typed_namespace() { use super::super::{BuildTarget, Extension, ExtensionKind}; use crate::libs::Ecosystem; let mut catalog = manifest(); catalog.extensions.push(Extension { kind: ExtensionKind::Runtime(Ecosystem::Pip), target: BuildTarget::LinuxX86_64, artifacts: vec![Artifact { path: "runtime/pip/linux-x86_64/runtime.pack".into(), bytes: 4, sha256: catalog.artifacts[0].sha256.clone(), }], }); validate_catalog(&catalog).unwrap(); assert_eq!( catalog.extensions[0].id().asset_suffix(), "ext-runtime-pip-linux-x86_64.tar.zst" ); let mut duplicate = catalog.clone(); duplicate.extensions.push(duplicate.extensions[0].clone()); assert!(validate_catalog(&duplicate).is_err()); for path in [ "dever-core", "runtime/npm/linux-x86_64/runtime.pack", "runtime/pip/linux-aarch64/runtime.pack", "runtime/pip/linux-x86_64/../escape", ] { catalog.extensions[0].artifacts[0].path = path.into(); assert!(validate_catalog(&catalog).is_err(), "{path}"); } } #[test] fn explicit_download_verifies_the_complete_bundle_before_publishing_catalog() { let root = Download::new(&std::env::temp_dir()).unwrap(); let layout = Layout::new(root.0.join("machine")); layout.initialize().unwrap(); let (manifest, bytes, signature, key) = signed_release(); fs::write( layout.state().join("trusted-release-key"), super::super::release::hex(&key), ) .unwrap(); let (source, worker) = server(vec![bytes, signature, compressed_release(b"core")]); let version = prepare_from(&layout, "latest", &source).unwrap(); worker.join().unwrap(); assert_eq!(version, manifest.version); let installed = layout.downloads().join(version.as_str()); MachineManager::verify_package(&installed, Some(&version), &key).unwrap(); assert_eq!( fs::read(installed.join("skills/dever-language/SKILL.md")).unwrap(), b"skill" ); assert_eq!( fs::read_to_string(layout.downloads().join("latest")).unwrap(), "1.2.3\n" ); assert_eq!(fs::read_dir(layout.staging()).unwrap().count(), 0); } #[test] fn latest_manifest_must_match_the_selected_tag_before_publication() { let root = Download::new(&std::env::temp_dir()).unwrap(); let layout = Layout::new(root.0.join("machine")); layout.initialize().unwrap(); let (_, bytes, signature, key) = signed_release(); fs::write( layout.state().join("trusted-release-key"), super::super::release::hex(&key), ) .unwrap(); fs::write(layout.downloads().join("latest"), "0.9.0\n").unwrap(); let (source, worker) = release_server("latest", "v1.2.4", vec![bytes, signature]); let error = prepare_from(&layout, "latest", &source).unwrap_err(); worker.join().unwrap(); assert!(error.contains("selected release tag"), "{error}"); assert_eq!( fs::read_to_string(layout.downloads().join("latest")).unwrap(), "0.9.0\n" ); assert!(!layout.downloads().join("1.2.3").exists()); assert!(!layout.downloads().join("1.2.4").exists()); assert_eq!(fs::read_dir(layout.staging()).unwrap().count(), 0); } #[test] fn bad_signatures_and_corrupt_payloads_preserve_the_previous_catalog_and_active_version() { for bad_signature in [true, false] { let root = Download::new(&std::env::temp_dir()).unwrap(); let layout = Layout::new(root.0.join("machine")); layout.initialize().unwrap(); let (_, bytes, signature, key) = signed_release(); fs::write( layout.state().join("trusted-release-key"), super::super::release::hex(&key), ) .unwrap(); fs::write(layout.downloads().join("latest"), "0.9.0\n").unwrap(); let active = layout.state().join("active-version"); super::super::release::append_active_version(&active, &Version::parse("0.9.0").unwrap()) .unwrap(); let previous = fs::read(&active).unwrap(); let responses = if bad_signature { vec![bytes, vec![b'0'; 128]] } else { vec![bytes, signature, compressed_release(b"evil")] }; let (source, worker) = server(responses); let error = prepare_from(&layout, "latest", &source).unwrap_err(); worker.join().unwrap(); assert!(error.contains("verification"), "{error}"); assert_eq!(fs::read(&active).unwrap(), previous); assert_eq!( fs::read_to_string(layout.downloads().join("latest")).unwrap(), "0.9.0\n" ); assert!(!layout.downloads().join("1.2.3").exists()); assert_eq!(fs::read_dir(layout.staging()).unwrap().count(), 0); } }