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:
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)