diff --git a/crates/rust-release-manifest/src/package_audit.rs b/crates/rust-release-manifest/src/package_audit.rs index 39312d1..57bcca2 100644 --- a/crates/rust-release-manifest/src/package_audit.rs +++ b/crates/rust-release-manifest/src/package_audit.rs @@ -23,6 +23,15 @@ use xz2::read::XzDecoder; const MAX_MEMBER_BYTES: u64 = 256 * 1024 * 1024; const INSTALL_NOTES: &[u8] = include_bytes!("../../../packaging/INSTALL-NOTES"); +pub(crate) const DEB_COPYRIGHT: &[u8] = concat!( + "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n", + "Upstream-Name: solstone-linux\n", + "Source: https://github.com/solpbc/solstone-linux\n", + "Copyright: 2026 sol pbc\n", + "License: AGPL-3.0-only\n", + include_str!("../../../LICENSE") +) +.as_bytes(); const PACKAGE_NOTE_PHRASES: &[&str] = &["observer key", "pipx"]; // Derived with: @@ -201,14 +210,23 @@ fn regular_artifact(path: &Path) -> Result<()> { } fn normalized_path(path: &Path) -> Option { - if path.is_absolute() - || path - .components() - .any(|component| !matches!(component, Component::Normal(_))) - { + if path.is_absolute() { return None; } - path.to_str().map(str::to_owned) + let mut normalized = String::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(value) => { + if !normalized.is_empty() { + normalized.push('/'); + } + normalized.push_str(value.to_str()?); + } + Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None, + } + } + (!normalized.is_empty()).then_some(normalized) } fn tar_inventory(artifact: &Path, reader: R) -> Result> { @@ -517,11 +535,52 @@ fn deb_members(path: &Path) -> Result> { let control_body = std::str::from_utf8(&control_member.bytes) .map_err(|_| audit_error(path, "MalformedMetadata", "non-utf8-control", "deb"))?; let mut fields = BTreeMap::new(); + let mut current = None; + let mut paragraph_ended = false; for line in control_body.lines() { + if line.is_empty() { + paragraph_ended = true; + current = None; + continue; + } + if paragraph_ended { + return Err(audit_error( + path, + "MalformedMetadata", + "control-paragraph", + "deb", + )); + } + if line.starts_with([' ', '\t']) { + let name = current.as_ref().ok_or_else(|| { + audit_error(path, "MalformedMetadata", "control-continuation", "deb") + })?; + let value: &mut String = fields + .get_mut(name) + .expect("current field is inserted before continuations"); + value.push('\n'); + value.push_str(line); + continue; + } let (name, value) = line .split_once(':') .ok_or_else(|| audit_error(path, "MalformedMetadata", "control-line", "deb"))?; - if fields.insert(name, value.trim()).is_some() { + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err(audit_error( + path, + "MalformedMetadata", + "control-field", + "deb", + )); + } + if fields + .insert(name.to_owned(), value.trim().to_owned()) + .is_some() + { return Err(audit_error( path, "MalformedMetadata", @@ -529,6 +588,7 @@ fn deb_members(path: &Path) -> Result> { name, )); } + current = Some(name.to_owned()); } for required in ["Package", "Version", "Architecture"] { if !fields.contains_key(required) { @@ -743,6 +803,23 @@ fn inspect_payload( .collect::>(); let mut executable = None; let mut nonbinary = BTreeMap::new(); + if matches!(format, Format::Deb) { + let expected = "usr/share/doc/solstone-linux/copyright"; + let copyright = by_path + .remove(expected) + .ok_or_else(|| audit_error(path, "PayloadClosure", "missing", expected))?; + if copyright.mode & 0o7777 != 0o644 { + return Err(audit_error( + path, + "PayloadClosure", + &format!("mode:{:04o}", copyright.mode), + expected, + )); + } + if copyright.bytes != DEB_COPYRIGHT { + return Err(audit_error(path, "DivergentPayload", "copyright", expected)); + } + } for authority in PAYLOAD_AUTHORITY { let expected = expected_path(format, authority); let member = by_path @@ -962,7 +1039,7 @@ mod tests { } fn fixture_members(format: Format) -> Vec { - PAYLOAD_AUTHORITY + let mut members = PAYLOAD_AUTHORITY .into_iter() .map(|authority| { let bytes = match authority.role { @@ -977,7 +1054,15 @@ mod tests { bytes, } }) - .collect() + .collect::>(); + if matches!(format, Format::Deb) { + members.push(Member { + path: "usr/share/doc/solstone-linux/copyright".into(), + mode: 0o644, + bytes: DEB_COPYRIGHT.to_vec(), + }); + } + members } fn exact(format: Format, class: &str, token: &str, member: &str, error: Error) { @@ -1000,6 +1085,49 @@ mod tests { } } + #[test] + fn deb_copyright_is_required_mode_locked_and_byte_exact() { + let expected = "usr/share/doc/solstone-linux/copyright"; + let mut missing = fixture_members(Format::Deb); + missing.retain(|member| member.path != expected); + exact( + Format::Deb, + "PayloadClosure", + "missing", + expected, + inspect_payload(artifact(Format::Deb), Format::Deb, missing).unwrap_err(), + ); + + let mut changed = fixture_members(Format::Deb); + changed + .iter_mut() + .find(|member| member.path == expected) + .unwrap() + .bytes + .push(b' '); + exact( + Format::Deb, + "DivergentPayload", + "copyright", + expected, + inspect_payload(artifact(Format::Deb), Format::Deb, changed).unwrap_err(), + ); + + let mut executable = fixture_members(Format::Deb); + executable + .iter_mut() + .find(|member| member.path == expected) + .unwrap() + .mode = 0o755; + exact( + Format::Deb, + "PayloadClosure", + "mode:0755", + expected, + inspect_payload(artifact(Format::Deb), Format::Deb, executable).unwrap_err(), + ); + } + #[test] fn deb_md5sums_are_closed_and_digest_bound() { assert_eq!(md5_digest(b""), "d41d8cd98f00b204e9800998ecf8427e"); @@ -1311,8 +1439,17 @@ mod tests { #[test] fn tar_inventory_rejects_duplicate_links_devices_and_truncation() { let artifact = Path::new("fixture.tar.gz"); + let dot_prefixed = tar_fixture(&[("./member", b"a", EntryType::Regular, 0o644)]); + assert_eq!( + tar_inventory(artifact, Cursor::new(dot_prefixed)) + .unwrap() + .into_iter() + .map(|member| member.path) + .collect::>(), + vec!["member"] + ); let duplicate = tar_fixture(&[ - ("member", b"a", EntryType::Regular, 0o644), + ("./member", b"a", EntryType::Regular, 0o644), ("member", b"b", EntryType::Regular, 0o644), ]); exact( diff --git a/crates/rust-release-manifest/src/proof_tests.rs b/crates/rust-release-manifest/src/proof_tests.rs index 7b5d6e6..8a7e7c6 100644 --- a/crates/rust-release-manifest/src/proof_tests.rs +++ b/crates/rust-release-manifest/src/proof_tests.rs @@ -1953,10 +1953,15 @@ fn sha256_git_fixture_flows_through_context_lane_status_and_recovery() { ); } -fn docker_create_templates(root: &RepoRoot, directory: &Path, executables: Option<[&[u8]; 3]>) { - let products = executables.map_or_else(crate::tests::release_fixture, |values| { - crate::tests::release_fixture_with(values) - }); +fn docker_create_templates(root: &RepoRoot, directory: &Path, divergent: Option) { + let baseline = crate::elf64::pinned_elf64_for_test(); + let mut alternate = baseline.clone(); + alternate.push(0); + let mut executables = [baseline.as_slice(); 3]; + if let Some(index) = divergent { + executables[index] = &alternate; + } + let products = crate::tests::audit_release_fixture_with(executables); for entry in fs::read_dir(products.path()).unwrap() { let entry = entry.unwrap(); fs::copy(entry.path(), directory.join(entry.file_name())).unwrap(); @@ -2058,7 +2063,7 @@ fn docker_create_candidate_is_offline_normalized_and_recoverable() { docker_create_candidate_harness(None); } -fn docker_create_candidate_harness(divergent: Option<[&[u8]; 3]>) { +fn docker_create_candidate_harness(divergent: Option) { let repo = crate::candidate_tests::fixture(); let db = crate::candidate_tests::git_repo(); let descriptor_dir = tempfile::tempdir().unwrap(); @@ -2181,7 +2186,7 @@ exit 94 .unwrap_err() .to_string(); assert!( - error.contains("candidate executable identity mismatch"), + error.contains("class=DivergentExecutable"), "unexpected creation error: {error}" ); assert!(!error.contains("candidate-proven")); @@ -2280,15 +2285,15 @@ exit 94 #[test] fn production_creation_rejects_divergent_tar_executable() { - docker_create_candidate_harness(Some(divergent_executables(0))); + docker_create_candidate_harness(Some(0)); } #[test] fn production_creation_rejects_divergent_deb_executable() { - docker_create_candidate_harness(Some(divergent_executables(1))); + docker_create_candidate_harness(Some(1)); } #[test] fn production_creation_rejects_divergent_rpm_executable() { - docker_create_candidate_harness(Some(divergent_executables(2))); + docker_create_candidate_harness(Some(2)); } diff --git a/crates/rust-release-manifest/src/tests.rs b/crates/rust-release-manifest/src/tests.rs index 8776248..0193640 100644 --- a/crates/rust-release-manifest/src/tests.rs +++ b/crates/rust-release-manifest/src/tests.rs @@ -487,6 +487,21 @@ fn audit_deb_with( ) -> PathBuf { let mut data = tar::Builder::new(Vec::new()); let mut md5sums = String::new(); + let copyright_path = "usr/share/doc/solstone-linux/copyright"; + let mut copyright_header = tar::Header::new_gnu(); + copyright_header.set_size(crate::package_audit::DEB_COPYRIGHT.len() as u64); + copyright_header.set_mode(0o644); + copyright_header.set_cksum(); + data.append_data( + &mut copyright_header, + format!("./{copyright_path}"), + crate::package_audit::DEB_COPYRIGHT, + ) + .unwrap(); + md5sums.push_str(&format!( + "{} {copyright_path}\n", + crate::package_audit::md5_digest(crate::package_audit::DEB_COPYRIGHT) + )); for authority in crate::package_audit::PAYLOAD_AUTHORITY { if payload.omit == Some(authority.role) { continue; @@ -909,7 +924,7 @@ fn package_audit_rejects_each_deb_dependency_and_accepts_product_name() { let deb = audit_deb_with( root.path(), &executable, - b"Package: solstone-linux\nVersion: 1.0.0-1\nArchitecture: amd64\nDepends: libc6, solstone-linux\n", + b"Package: solstone-linux\nVersion: 1.0.0-1\nArchitecture: amd64\nDepends: libc6, solstone-linux\nDescription: solstone-linux\n A continuation line is valid Debian control syntax.\n .\n So is a second paragraph in the field.\n", &[], &AuditPayloadOptions::default(), ); @@ -1348,6 +1363,14 @@ pub(super) fn release_fixture_with(executables: [&[u8]; 3]) -> tempfile::TempDir temp } +pub(super) fn audit_release_fixture_with(executables: [&[u8]; 3]) -> tempfile::TempDir { + let temp = tempfile::tempdir().unwrap(); + audit_tar(temp.path(), executables[0]); + audit_deb(temp.path(), executables[1]); + audit_rpm(temp.path(), executables[2]); + temp +} + #[test] fn rust_release_manifest_conformance() { verify_schema().unwrap(); diff --git a/crates/rust-release-manifest/src/transaction.rs b/crates/rust-release-manifest/src/transaction.rs index f78cd81..5e45943 100644 --- a/crates/rust-release-manifest/src/transaction.rs +++ b/crates/rust-release-manifest/src/transaction.rs @@ -1736,6 +1736,18 @@ fn create_candidate_locked( })?; let baseline_executable = reconcile_lanes(&deb, &rpm, &staging.deb_lane, &staging.rpm_lane, &version)?; + audit_packages( + &staging + .deb_lane + .join(&artifact_by_kind(&deb.artifacts, "tar")?.path), + &staging + .deb_lane + .join(&artifact_by_kind(&deb.artifacts, "deb")?.path), + &staging + .rpm_lane + .join(&artifact_by_kind(&rpm.artifacts, "rpm")?.path), + &baseline_executable.sha256, + )?; let finalized = finalize_candidate(FinalizeInput { root, staging: &staging, diff --git a/crates/solstone-linux/Cargo.toml b/crates/solstone-linux/Cargo.toml index 236d9e1..242e4f3 100644 --- a/crates/solstone-linux/Cargo.toml +++ b/crates/solstone-linux/Cargo.toml @@ -62,6 +62,7 @@ toml = "1" # prefix is cargo-deb's special workspace-aware spelling for a built binary. [package.metadata.deb] maintainer = "sol pbc " +copyright = "2026 sol pbc" license-file = ["../../LICENSE", "0"] extended-description = "A standalone Linux desktop observer that experiences screen and audio along with its owner and syncs segments to their solstone journal." depends = "$auto" diff --git a/crates/solstone-linux/src/release_rail_tests.rs b/crates/solstone-linux/src/release_rail_tests.rs index dec3559..5275175 100644 --- a/crates/solstone-linux/src/release_rail_tests.rs +++ b/crates/solstone-linux/src/release_rail_tests.rs @@ -106,6 +106,7 @@ fn package_metadata_and_resolved_licenses() { let deb = &member["package"]["metadata"]["deb"]; assert!(deb.get("license").is_none()); + assert_eq!(deb["copyright"].as_str(), Some("2026 sol pbc")); assert_eq!( deb["license-file"].as_array().unwrap(), &[ diff --git a/packaging/Containerfile b/packaging/Containerfile index da6a95c..ba3cccc 100644 --- a/packaging/Containerfile +++ b/packaging/Containerfile @@ -66,10 +66,21 @@ RUN actual="$(cargo deb --version)" \ && cargo deb --locked --no-build -p solstone-linux \ && VERSION=$(cat /release/VERSION) \ && test "$VERSION" = "$RELEASE_VERSION" \ - && DEB="target/debian/solstone-linux_${VERSION}-1_amd64.deb" \ + && DEB="/src/target/debian/solstone-linux_${VERSION}-1_amd64.deb" \ && { test -f "$DEB" \ || { echo "error: Debian artifact mismatch: expected ${DEB}, actual missing" >&2; \ echo "repair: cargo deb --locked --no-build -p solstone-linux" >&2; exit 1; }; } \ + && DEB_ROOT=$(mktemp -d) \ + && dpkg-deb --raw-extract "$DEB" "$DEB_ROOT" \ + && cd "$DEB_ROOT" \ + && find . -path ./DEBIAN -prune -o -type f -printf '%P\0' \ + | LC_ALL=C sort -z \ + | xargs -0 -r md5sum -- > DEBIAN/md5sums \ + && test "$(wc -l < DEBIAN/md5sums)" -eq 17 \ + && chmod 0644 DEBIAN/md5sums \ + && dpkg-deb --root-owner-group -Zxz -z9 --build "$DEB_ROOT" "${DEB}.sealed" >/dev/null \ + && mv "${DEB}.sealed" "$DEB" \ + && cd /src \ && DEB_OUT="/release/solstone-linux_${VERSION}-1_amd64.deb" \ && TAR_OUT="/release/solstone-linux-${VERSION}-linux-x86_64.tar.gz" \ && cp "$DEB" "$DEB_OUT" \