app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 2c6c30842e47c8678b38b9732a16793bd7bdf31d
parent 56b89be7bfd762f50a884cb1d7562753bae86063
Author: triesap <tyson@radroots.org>
Date:   Fri,  4 Sep 2026 21:08:29 +0000

build: validate actual Lib source-lock bytes

- bind the legacy source lock to the exact Cargo lock path and digest
- reject malformed, oversized, non-regular, and symlinked lock inputs
- wire bounded no-follow validation into the Gradle source gate
- align the Rust provenance audit and adversarial regression coverage

Diffstat:
Abuild-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/RadrootsLibSourceLock.kt | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbuild-logic/contracts/src/test/kotlin/org/harvestcircle/buildlogic/contracts/BuildContractsTest.kt | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRootPlugin.kt | 5+++++
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/ProductCoordinatesTask.kt | 18++++++++++++++++++
Mradroots.lib.source-lock.v1.toml | 3++-
Mtools/xtask/Cargo.lock | 1+
Mtools/xtask/Cargo.toml | 1+
Mtools/xtask/src/lib.rs | 223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
8 files changed, 492 insertions(+), 6 deletions(-)

diff --git a/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/RadrootsLibSourceLock.kt b/build-logic/contracts/src/main/kotlin/org/harvestcircle/buildlogic/contracts/RadrootsLibSourceLock.kt @@ -0,0 +1,163 @@ +package org.harvestcircle.buildlogic.contracts + +import java.io.File +import java.nio.ByteBuffer +import java.nio.channels.SeekableByteChannel +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes +import java.security.MessageDigest + +public class RadrootsLibSourceLock private constructor( + public val lockfile: String, + public val lockfileSha256: String, +) { + public companion object { + private const val MAX_SOURCE_LOCK_BYTES: Long = 1024L * 1024L + private const val MAX_LOCKFILE_BYTES: Long = 32L * 1024L * 1024L + private val assignment = Regex("^([a-z0-9_]+) = \\\"([^\\\"]+)\\\"$") + private val requiredKeys = + listOf( + "schema", + "repository", + "revision", + "architecture", + "workspace_catalog_sha256", + "version", + "source_archive_sha256", + "lockfile", + "lockfile_sha256", + ) + private val fixedValues = + mapOf( + "schema" to "radroots.lib.source-lock.v1", + "repository" to "https://github.com/radrootslabs/lib", + "revision" to "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb", + "architecture" to "radroots.crates.release.v2", + "workspace_catalog_sha256" to "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4", + "version" to "0.1.0-alpha", + "source_archive_sha256" to "2cf12c24ed649c3c8dd48cebcb8583996646e116fc2472539a55748c803584db", + ) + + public fun load( + sourceLockFile: File, + repositoryRoot: File, + ): RadrootsLibSourceLock { + val root = repositoryRoot.toPath().toAbsolutePath().normalize() + val sourceLockPath = sourceLockFile.toPath().toAbsolutePath().normalize() + require(sourceLockPath.startsWith(root)) { "Lib source lock must be inside the repository root" } + val relativeSourceLock = root.relativize(sourceLockPath) + val source = + readBoundedNoFollow(root, relativeSourceLock, MAX_SOURCE_LOCK_BYTES) { bytes -> + bytes.toString(Charsets.UTF_8) + } + val parsed = parse(source) + val actualDigest = + readBoundedNoFollow(root, Path.of(parsed.lockfile), MAX_LOCKFILE_BYTES) { bytes -> + MessageDigest + .getInstance("SHA-256") + .digest(bytes) + .joinToString("") { byte -> "%02x".format(byte) } + } + require(actualDigest == parsed.lockfileSha256) { + "Lib source-lock digest does not match the actual bounded lockfile bytes" + } + return parsed + } + + public fun parse(source: String): RadrootsLibSourceLock { + require(!source.startsWith('\uFEFF')) { "Lib source lock must not contain a UTF-8 BOM" } + require(source.endsWith('\n') && '\r' !in source) { + "Lib source lock must use canonical LF-terminated UTF-8 text" + } + val values = linkedMapOf<String, String>() + source.dropLast(1).split('\n').forEachIndexed { index, line -> + val match = requireNotNull(assignment.matchEntire(line)) { + "Lib source-lock line ${index + 1} is not a canonical string assignment" + } + val key = match.groupValues[1] + val value = match.groupValues[2] + require(key in requiredKeys) { "Unknown Lib source-lock key: $key" } + require(values.put(key, value) == null) { "Duplicate Lib source-lock key: $key" } + } + require(values.keys.toList() == requiredKeys) { "Lib source-lock keys or ordering differ" } + fixedValues.forEach { (key, expected) -> + require(values.getValue(key) == expected) { "Lib source-lock $key differs" } + } + require(values.getValue("lockfile") == "core/Cargo.lock") { + "Lib source-lock lockfile path differs" + } + require(values.getValue("lockfile_sha256").isCanonicalHex(64)) { + "Lib source-lock lockfile digest is not canonical SHA-256" + } + validateRelativePath(Path.of(values.getValue("lockfile"))) + return RadrootsLibSourceLock( + lockfile = values.getValue("lockfile"), + lockfileSha256 = values.getValue("lockfile_sha256"), + ) + } + + private fun <T> readBoundedNoFollow( + root: Path, + relative: Path, + maximumBytes: Long, + transform: (ByteArray) -> T, + ): T { + validateRelativePath(relative) + var current = root + relative.forEachIndexed { index, component -> + current = current.resolve(component) + val attributes = + Files.readAttributes( + current, + BasicFileAttributes::class.java, + LinkOption.NOFOLLOW_LINKS, + ) + require(!attributes.isSymbolicLink) { "Source-lock path must not traverse a symbolic link" } + if (index < relative.nameCount - 1) { + require(attributes.isDirectory) { "Source-lock path parent must be a directory" } + } else { + require(attributes.isRegularFile) { "Source-lock path must identify a regular file" } + require(attributes.size() <= maximumBytes) { "Source-lock input exceeds its byte limit" } + } + } + + return Files.newByteChannel( + current, + StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS, + ).use { channel -> + transform(readBounded(channel, maximumBytes)) + } + } + + private fun readBounded( + channel: SeekableByteChannel, + maximumBytes: Long, + ): ByteArray { + val admittedSize = channel.size() + require(admittedSize <= maximumBytes) { "Source-lock input exceeds its byte limit" } + val output = ByteArray(admittedSize.toInt()) + val buffer = ByteBuffer.wrap(output) + while (buffer.hasRemaining()) { + require(channel.read(buffer) >= 0) { "Source-lock input was truncated while reading" } + } + val probe = ByteBuffer.allocate(1) + require(channel.read(probe) == -1) { "Source-lock input grew beyond its admitted size" } + return output + } + + private fun validateRelativePath(path: Path) { + require(!path.isAbsolute && path.nameCount > 0 && path.normalize() == path) { + "Lib source-lock path must be a normalized relative path" + } + path.forEach { component -> + require(component.toString() !in setOf("", ".", "..")) { + "Lib source-lock path contains an unsafe component" + } + } + } + } +} diff --git a/build-logic/contracts/src/test/kotlin/org/harvestcircle/buildlogic/contracts/BuildContractsTest.kt b/build-logic/contracts/src/test/kotlin/org/harvestcircle/buildlogic/contracts/BuildContractsTest.kt @@ -1,5 +1,12 @@ package org.harvestcircle.buildlogic.contracts +import java.nio.file.Files +import java.security.MessageDigest +import kotlin.io.path.createDirectories +import kotlin.io.path.createDirectory +import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeBytes +import kotlin.io.path.writeText import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails @@ -8,6 +15,64 @@ import kotlin.test.assertTrue class BuildContractsTest { @Test + fun radrootsLibSourceLockHashesActualBoundedNoFollowBytes() { + val root = createTempDirectory("harvestcircle-source-lock-") + val lockfile = root.resolve("core/Cargo.lock") + lockfile.parent.createDirectories() + val original = "version = 4\n".toByteArray() + lockfile.writeBytes(original) + val sourceLock = root.resolve("radroots.lib.source-lock.v1.toml") + sourceLock.writeText(radrootsLibSourceLock(sha256(original))) + + val parsed = RadrootsLibSourceLock.load(sourceLock.toFile(), root.toFile()) + assertEquals("core/Cargo.lock", parsed.lockfile) + assertEquals(sha256(original), parsed.lockfileSha256) + + lockfile.writeText("version = 3\n") + assertFails { RadrootsLibSourceLock.load(sourceLock.toFile(), root.toFile()) } + lockfile.writeBytes(original) + sourceLock.writeText(radrootsLibSourceLock("a".repeat(64))) + assertFails { RadrootsLibSourceLock.load(sourceLock.toFile(), root.toFile()) } + + val canonical = radrootsLibSourceLock(sha256(original)) + assertFails { RadrootsLibSourceLock.parse(canonical.replace("lockfile =", "unknown =")) } + assertFails { RadrootsLibSourceLock.parse(canonical.replace("lockfile = \"core/Cargo.lock\"\n", "")) } + assertFails { RadrootsLibSourceLock.parse(canonical + "lockfile = \"core/Cargo.lock\"\n") } + assertFails { RadrootsLibSourceLock.parse(canonical.replace("core/Cargo.lock", "../Cargo.lock")) } + assertFails { RadrootsLibSourceLock.parse(canonical.replace("core/Cargo.lock", "/tmp/Cargo.lock")) } + assertFails { RadrootsLibSourceLock.parse(canonical.replace("\n", "\r\n")) } + + val oversized = root.resolve("oversized-source-lock.toml") + oversized.writeBytes(ByteArray(1024 * 1024 + 1)) + assertFails { RadrootsLibSourceLock.load(oversized.toFile(), root.toFile()) } + } + + @Test + fun radrootsLibSourceLockRejectsNonregularAndSymlinkPaths() { + val finalLinkRoot = createTempDirectory("harvestcircle-source-lock-final-link-") + finalLinkRoot.resolve("core").createDirectories() + val outside = finalLinkRoot.resolve("outside.lock").apply { writeText("version = 4\n") } + Files.createSymbolicLink(finalLinkRoot.resolve("core/Cargo.lock"), outside) + val finalLinkAuthority = finalLinkRoot.resolve("radroots.lib.source-lock.v1.toml") + finalLinkAuthority.writeText(radrootsLibSourceLock(sha256(outside.toFile().readBytes()))) + assertFails { RadrootsLibSourceLock.load(finalLinkAuthority.toFile(), finalLinkRoot.toFile()) } + + val intermediateLinkRoot = createTempDirectory("harvestcircle-source-lock-intermediate-link-") + val actual = intermediateLinkRoot.resolve("actual").createDirectories() + actual.resolve("Cargo.lock").writeText("version = 4\n") + Files.createSymbolicLink(intermediateLinkRoot.resolve("core"), actual) + val intermediateAuthority = intermediateLinkRoot.resolve("radroots.lib.source-lock.v1.toml") + intermediateAuthority.writeText(radrootsLibSourceLock(sha256(actual.resolve("Cargo.lock").toFile().readBytes()))) + assertFails { RadrootsLibSourceLock.load(intermediateAuthority.toFile(), intermediateLinkRoot.toFile()) } + + val directoryRoot = createTempDirectory("harvestcircle-source-lock-directory-") + directoryRoot.resolve("core").createDirectories().resolve("Cargo.lock").createDirectory() + val directoryAuthority = directoryRoot.resolve("radroots.lib.source-lock.v1.toml") + directoryAuthority.writeText(radrootsLibSourceLock(sha256(byteArrayOf()))) + assertFails { RadrootsLibSourceLock.load(directoryAuthority.toFile(), directoryRoot.toFile()) } + } + + @Test fun productCoordinatesAreCanonicalAndMatchTheMigrationAdapterFixture() { val coordinates = ProductCoordinates.parse(productCoordinates) @@ -208,6 +273,25 @@ class BuildContractsTest { copyright.notice=Copyright © 2026 HarvestCircle contributors """.trimIndent() + "\n" + private fun radrootsLibSourceLock(lockfileSha256: String): String = + """ + schema = "radroots.lib.source-lock.v1" + repository = "https://github.com/radrootslabs/lib" + revision = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" + architecture = "radroots.crates.release.v2" + workspace_catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4" + version = "0.1.0-alpha" + source_archive_sha256 = "2cf12c24ed649c3c8dd48cebcb8583996646e116fc2472539a55748c803584db" + lockfile = "core/Cargo.lock" + lockfile_sha256 = "$lockfileSha256" + """.trimIndent() + "\n" + + private fun sha256(bytes: ByteArray): String = + MessageDigest + .getInstance("SHA-256") + .digest(bytes) + .joinToString("") { byte -> "%02x".format(byte) } + private val ffiBaseline = """ schema=harvestcircle.ffi.v4 diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRootPlugin.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRootPlugin.kt @@ -26,6 +26,11 @@ public class HarvestCircleRootPlugin : Plugin<Project> { task.sourceProvenanceFile.set( target.layout.projectDirectory.file("core/provenance/harvestcircle-v1.toml"), ) + task.radrootsLibSourceLockFile.set( + target.layout.projectDirectory.file("radroots.lib.source-lock.v1.toml"), + ) + task.cargoLockFile.set(target.layout.projectDirectory.file("core/Cargo.lock")) + task.repositoryRoot.set(target.layout.projectDirectory) } target.tasks.register("verifyCompatibilityBaseline") { task -> diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/ProductCoordinatesTask.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/ProductCoordinatesTask.kt @@ -1,14 +1,17 @@ package org.harvestcircle.buildlogic.plugins.tasks import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.work.DisableCachingByDefault import org.harvestcircle.buildlogic.contracts.FfiCompatibilityBaseline import org.harvestcircle.buildlogic.contracts.ProductCoordinates +import org.harvestcircle.buildlogic.contracts.RadrootsLibSourceLock import org.harvestcircle.buildlogic.contracts.SourceProvenance @DisableCachingByDefault(because = "Product coordinate verification produces no reusable output") @@ -29,6 +32,17 @@ abstract class VerifyProductCoordinates : DefaultTask() { @get:PathSensitive(PathSensitivity.RELATIVE) abstract val sourceProvenanceFile: RegularFileProperty + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val radrootsLibSourceLockFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val cargoLockFile: RegularFileProperty + + @get:Internal + abstract val repositoryRoot: DirectoryProperty + @TaskAction fun verify() { val source = manifestFile.get().asFile.readText() @@ -52,5 +66,9 @@ abstract class VerifyProductCoordinates : DefaultTask() { val provenance = SourceProvenance.load(sourceProvenanceFile.get().asFile) check(baseline["source.provenance_digest"] == provenance.digest) check(provenance.foundationBaseline == baseline["source.foundation_baseline"]) + RadrootsLibSourceLock.load( + sourceLockFile = radrootsLibSourceLockFile.get().asFile, + repositoryRoot = repositoryRoot.get().asFile, + ) } } diff --git a/radroots.lib.source-lock.v1.toml b/radroots.lib.source-lock.v1.toml @@ -5,4 +5,5 @@ architecture = "radroots.crates.release.v2" workspace_catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4" version = "0.1.0-alpha" source_archive_sha256 = "2cf12c24ed649c3c8dd48cebcb8583996646e116fc2472539a55748c803584db" -lockfile_sha256 = "4308984326ef320973bd11c78dabad499fd57ea8be8e12d5a57bbe8464196a7a" +lockfile = "core/Cargo.lock" +lockfile_sha256 = "d4454a053e5f5d1810170fe9987e0f2a1d365de7de3eb9c71599029e46a03fc3" diff --git a/tools/xtask/Cargo.lock b/tools/xtask/Cargo.lock @@ -60,6 +60,7 @@ dependencies = [ name = "harvestcircle_xtask" version = "0.1.0-alpha" dependencies = [ + "libc", "sha2", ] diff --git a/tools/xtask/Cargo.toml b/tools/xtask/Cargo.toml @@ -9,6 +9,7 @@ publish = false [workspace] [dependencies] +libc = "0.2.189" sha2 = "0.10.9" [lints.rust] diff --git a/tools/xtask/src/lib.rs b/tools/xtask/src/lib.rs @@ -1,10 +1,14 @@ use sha2::{Digest, Sha256}; use std::collections::BTreeSet; -use std::fs; -use std::path::{Path, PathBuf}; +use std::fs::{self, OpenOptions}; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; use std::process::Command as ProcessCommand; use std::str::FromStr; +#[cfg(unix)] +use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Command { DesignSourceAudit, @@ -977,6 +981,8 @@ fn provenance_check(root: &Path, inventory: &Inventory, findings: &mut Vec<Strin const LIB_REVISION: &str = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb"; const PROVENANCE_PATH: &str = "core/provenance/harvestcircle-v1.toml"; const SOURCE_LOCK_PATH: &str = "radroots.lib.source-lock.v1.toml"; + const MAX_SOURCE_LOCK_BYTES: u64 = 1024 * 1024; + const MAX_CARGO_LOCK_BYTES: u64 = 32 * 1024 * 1024; let cargo = read_text(root, "core/Cargo.toml"); for authority in [ "repository = \"https://github.com/radrootslabs/harvestcircle\"".to_owned(), @@ -1030,12 +1036,46 @@ fn provenance_check(root: &Path, inventory: &Inventory, findings: &mut Vec<Strin "workspace_catalog_sha256 = \"deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4\"\n", "version = \"0.1.0-alpha\"\n", "source_archive_sha256 = \"2cf12c24ed649c3c8dd48cebcb8583996646e116fc2472539a55748c803584db\"\n", - "lockfile_sha256 = \"4308984326ef320973bd11c78dabad499fd57ea8be8e12d5a57bbe8464196a7a\"\n", + "lockfile = \"core/Cargo.lock\"\n", + "lockfile_sha256 = \"d4454a053e5f5d1810170fe9987e0f2a1d365de7de3eb9c71599029e46a03fc3\"\n", ); - if read_text(root, SOURCE_LOCK_PATH) != expected_source_lock { + let source_lock_bytes = + match bounded_no_follow_bytes(root, Path::new(SOURCE_LOCK_PATH), MAX_SOURCE_LOCK_BYTES) { + Ok(bytes) => bytes, + Err(error) => { + findings.push(format!("{SOURCE_LOCK_PATH}: {error}")); + Vec::new() + } + }; + let source_lock = String::from_utf8(source_lock_bytes).unwrap_or_default(); + if source_lock != expected_source_lock { findings.push(format!("{SOURCE_LOCK_PATH}: exact Lib source lock changed")); } - let cargo_lock = read_text(root, "core/Cargo.lock"); + let lockfile = exact_string_assignment(&source_lock, "lockfile"); + let declared_lockfile_sha256 = exact_string_assignment(&source_lock, "lockfile_sha256"); + let cargo_lock_bytes = lockfile + .as_deref() + .ok_or_else(|| "lockfile assignment is missing or duplicated".to_owned()) + .and_then(|path| bounded_no_follow_bytes(root, Path::new(path), MAX_CARGO_LOCK_BYTES)); + if let (Ok(bytes), Some(declared)) = (&cargo_lock_bytes, declared_lockfile_sha256.as_deref()) { + let actual = format!("{:x}", Sha256::digest(bytes)); + if actual != declared { + findings.push(format!( + "{SOURCE_LOCK_PATH}: lockfile_sha256 does not match actual bounded no-follow bytes" + )); + } + } else { + let error = cargo_lock_bytes + .as_ref() + .err() + .map(String::as_str) + .unwrap_or("lockfile_sha256 assignment is missing or duplicated"); + findings.push(format!("{SOURCE_LOCK_PATH}: {error}")); + } + let cargo_lock = cargo_lock_bytes + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .unwrap_or_default(); if !cargo_lock.contains(&format!( "source = \"git+https://github.com/radrootslabs/lib?rev={LIB_REVISION}#{LIB_REVISION}\"" )) { @@ -1479,6 +1519,116 @@ fn sha256_file(path: &Path) -> Option<String> { Some(format!("{:x}", Sha256::digest(bytes))) } +fn exact_string_assignment(source: &str, key: &str) -> Option<String> { + let prefix = format!("{key} = \""); + let values = source + .lines() + .filter_map(|line| { + let value = line.strip_prefix(&prefix)?.strip_suffix('"')?; + (!value.is_empty()).then(|| value.to_owned()) + }) + .collect::<Vec<_>>(); + (values.len() == 1).then(|| values[0].clone()) +} + +fn bounded_no_follow_bytes(root: &Path, relative: &Path, maximum: u64) -> Result<Vec<u8>, String> { + if relative.is_absolute() + || relative.components().next().is_none() + || relative + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err("path must be normalized and relative".to_owned()); + } + let components = relative.components().collect::<Vec<_>>(); + let mut path = root.to_path_buf(); + let mut admitted = None; + for (index, component) in components.iter().enumerate() { + path.push(component.as_os_str()); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("unable to inspect {}: {error}", relative.display()))?; + if metadata.file_type().is_symlink() { + return Err(format!( + "path traverses a symbolic link: {}", + relative.display() + )); + } + if index + 1 == components.len() { + if !metadata.is_file() { + return Err(format!( + "path is not a regular file: {}", + relative.display() + )); + } + if metadata.len() > maximum { + return Err(format!("file exceeds byte limit: {}", relative.display())); + } + admitted = Some(metadata); + } else if !metadata.is_dir() { + return Err(format!( + "path parent is not a directory: {}", + relative.display() + )); + } + } + + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + let mut file = options.open(&path).map_err(|error| { + format!( + "unable to open {} without following links: {error}", + relative.display() + ) + })?; + let opened = file + .metadata() + .map_err(|error| format!("unable to inspect opened {}: {error}", relative.display()))?; + if !opened.is_file() || opened.len() > maximum { + return Err(format!( + "opened path is not a bounded regular file: {}", + relative.display() + )); + } + #[cfg(unix)] + if admitted + .as_ref() + .is_some_and(|metadata| metadata.dev() != opened.dev() || metadata.ino() != opened.ino()) + { + return Err(format!( + "path identity changed before open: {}", + relative.display() + )); + } + + let mut bytes = Vec::with_capacity(opened.len() as usize); + file.by_ref() + .take(maximum + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("unable to read {}: {error}", relative.display()))?; + if bytes.len() as u64 > maximum { + return Err(format!("file exceeds byte limit: {}", relative.display())); + } + let completed = file + .metadata() + .map_err(|error| format!("unable to revalidate {}: {error}", relative.display()))?; + if completed.len() != bytes.len() as u64 { + return Err(format!( + "file changed while it was read: {}", + relative.display() + )); + } + #[cfg(unix)] + if opened.dev() != completed.dev() || opened.ino() != completed.ino() { + return Err(format!( + "file identity changed while it was read: {}", + relative.display() + )); + } + Ok(bytes) +} + fn relative(root: &Path, path: &Path) -> Result<String, String> { path.strip_prefix(root) .map(|relative| relative.to_string_lossy().replace('\\', "/")) @@ -2020,6 +2170,69 @@ mod tests { fs::remove_dir_all(root).expect("remove fixture"); } + #[test] + fn source_lock_digest_rejects_actual_byte_mismatch() { + let root = fixture("source-lock-digest"); + write( + &root, + "radroots.lib.source-lock.v1.toml", + concat!( + "schema = \"radroots.lib.source-lock.v1\"\n", + "repository = \"https://github.com/radrootslabs/lib\"\n", + "revision = \"ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb\"\n", + "architecture = \"radroots.crates.release.v2\"\n", + "workspace_catalog_sha256 = \"deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4\"\n", + "version = \"0.1.0-alpha\"\n", + "source_archive_sha256 = \"2cf12c24ed649c3c8dd48cebcb8583996646e116fc2472539a55748c803584db\"\n", + "lockfile = \"core/Cargo.lock\"\n", + "lockfile_sha256 = \"d4454a053e5f5d1810170fe9987e0f2a1d365de7de3eb9c71599029e46a03fc3\"\n", + ), + ); + write(&root, "core/Cargo.toml", ""); + write(&root, "core/Cargo.lock", "version = 3\n"); + let inventory = Inventory::load(&root).expect("source-lock inventory"); + let mut findings = Vec::new(); + provenance_check(&root, &inventory, &mut findings); + assert!(findings.iter().any(|finding| { + finding.contains("lockfile_sha256 does not match actual bounded no-follow bytes") + })); + fs::remove_dir_all(root).expect("remove fixture"); + } + + #[cfg(unix)] + #[test] + fn bounded_source_lock_reads_reject_final_and_intermediate_symlinks() { + use std::os::unix::fs::symlink; + + let root = fixture("source-lock-symlinks"); + write(&root, "actual/Cargo.lock", "version = 4\n"); + symlink(root.join("actual/Cargo.lock"), root.join("final.lock")) + .expect("create final symlink"); + assert!( + bounded_no_follow_bytes(&root, Path::new("final.lock"), 1024) + .expect_err("final symlink must fail") + .contains("symbolic link") + ); + + symlink(root.join("actual"), root.join("core")).expect("create intermediate symlink"); + assert!( + bounded_no_follow_bytes(&root, Path::new("core/Cargo.lock"), 1024) + .expect_err("intermediate symlink must fail") + .contains("symbolic link") + ); + assert_eq!( + bounded_no_follow_bytes(&root, Path::new("actual/Cargo.lock"), 1024) + .expect("regular bounded file"), + b"version = 4\n" + ); + assert!( + bounded_no_follow_bytes(&root, Path::new("actual/Cargo.lock"), 1) + .expect_err("oversize file must fail") + .contains("byte limit") + ); + fs::remove_dir_all(root).expect("remove fixture"); + } + fn fixture(name: &str) -> PathBuf { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH)