Skip to content

Commit bcafffa

Browse files
splitstream: Rework file format
This is a substantial change to the splitstream file format to add more features (required for ostree support) and to add forwards- and backwards- compatibility mechanisms for future changes. This change aims to finalize the file format so we can start shipping this to the systems of real users without future "breaks everything" changes. This change itself breaks everything: you'll need to delete your repository and start over. Hopefully this is the last time. The file format is substantially more fleshed-out at this point. Here's an overview of the changes: - there is a header with a magic value, a version. flags field, and the fs-verity algorithm number and block size in use - everything else in the file can be freely located which will help if we ever want to create a version of the writer that streams data to disk as it goes: in that case we may want to store the stream before the associated metadata - there is an expandable "info" section which contains most other information about the stream and is intended to be used as the primary mechanism for making compatible changes to the file format in the future - the info section stores the total decompressed/reassembled stream size and a unique identifier value for the file type stored in the stream - the referenced external objects and splitstreams are now stored in a flat array of binary fs-verity hash values to improve the performance of garbage collection operations in large repositories (informed by Alex's battlescars from dealing with GC on Flathub) - it is possible to add arbitrary external object and stream references - the "sha256 mapping" has been replaced with a more flexible "named stream refs" mechanism that allows assigning arbitrary names to associated streams. This will be useful if we ever want to support formats that are based on anything other than SHA-256 (including future OCI versions which may start using SHA-512 or something else). - whereas the previous implementation concerned itself with ensuring the correct SHA-256 content hash of the stream and creating a link to the stream with that hash value from the `streams/` directory, the new implementation requires that the user perform whatever hashing they consider appropriate and name their streams with a "content identifier". This change, taken together with the above change, removes all SHA-256 specific logic from the implementation. The main reason for this change is that a SHA-256 content hash over a file isn't a sufficiently unique identifier to locate the relevant splitstream for that file. Each different file type is split into a splitstream in a different way. It just so happens that OCI JSON documents, `.tar` files, and GVariant OSTree commit objects have no possible overlaps (which means that SHA-256 content hashes have uniquely identified the files up to this point), but this is mostly a coincidence. Each file type is now responsible to name its streams with a sufficiently unique "content identifier" based on the component name, the file name, and a content hash, for example: - `oci-commit-sha256:...` - `oci-layer-sha256:...` - `ostree-commit-...` - &c. Having the repository itself no longer care about the content hashes means that the OCI code can now trust the SHA-256 verification performed by skopeo, and we don't need to recompute it, which is a nice win. Update the file format documentation. Update the repository code and the users of splitstream (ie: OCI) to adjust to the post-sha256-hardcoded future. Adjust the way we deal with verification of OCI objects when we lack fs-verity digests: instead of having an "open" operation which verifies everything and a "shallow open" which doesn't, just have the open operation verify only the config and move the verification of the layers to when we access them. Co-authored-by: Alexander Larsson <[email protected]> Signed-off-by: Alexander Larsson <[email protected]> Signed-off-by: Allison Karlitskaya <[email protected]>
1 parent dcd3577 commit bcafffa

File tree

11 files changed

+815
-532
lines changed

11 files changed

+815
-532
lines changed

crates/cfsctl/src/main.rs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ pub struct App {
4848
enum OciCommand {
4949
/// Stores a tar file as a splitstream in the repository.
5050
ImportLayer {
51-
sha256: String,
51+
digest: String,
5252
name: Option<String>,
5353
},
5454
/// Lists the contents of a tar stream
@@ -105,7 +105,7 @@ enum Command {
105105
Transaction,
106106
/// Reconstitutes a split stream and writes it to stdout
107107
Cat {
108-
/// the name of the stream to cat, either a sha256 digest or prefixed with 'ref/'
108+
/// the name of the stream to cat, either a content identifier or prefixed with 'ref/'
109109
name: String,
110110
},
111111
/// Perform garbage collection
@@ -122,7 +122,7 @@ enum Command {
122122
},
123123
/// Mounts a composefs, possibly enforcing fsverity of the image
124124
Mount {
125-
/// the name of the image to mount, either a sha256 digest or prefixed with 'ref/'
125+
/// the name of the image to mount, either an fs-verity hash or prefixed with 'ref/'
126126
name: String,
127127
/// the mountpoint
128128
mountpoint: String,
@@ -194,18 +194,18 @@ async fn main() -> Result<()> {
194194
}
195195
}
196196
Command::Cat { name } => {
197-
repo.merge_splitstream(&name, None, &mut std::io::stdout())?;
197+
repo.merge_splitstream(&name, None, None, &mut std::io::stdout())?;
198198
}
199199
Command::ImportImage { reference } => {
200200
let image_id = repo.import_image(&reference, &mut std::io::stdin())?;
201201
println!("{}", image_id.to_id());
202202
}
203203
#[cfg(feature = "oci")]
204204
Command::Oci { cmd: oci_cmd } => match oci_cmd {
205-
OciCommand::ImportLayer { name, sha256 } => {
205+
OciCommand::ImportLayer { name, digest } => {
206206
let object_id = composefs_oci::import_layer(
207207
&Arc::new(repo),
208-
&composefs::util::parse_sha256(sha256)?,
208+
&digest,
209209
name.as_deref(),
210210
&mut std::io::stdin(),
211211
)?;
@@ -253,20 +253,20 @@ async fn main() -> Result<()> {
253253
println!("{}", image_id.to_id());
254254
}
255255
OciCommand::Pull { ref image, name } => {
256-
let (sha256, verity) =
256+
let (digest, verity) =
257257
composefs_oci::pull(&Arc::new(repo), image, name.as_deref(), None).await?;
258258

259-
println!("sha256 {}", hex::encode(sha256));
259+
println!("config {digest}");
260260
println!("verity {}", verity.to_hex());
261261
}
262262
OciCommand::Seal {
263263
ref config_name,
264264
ref config_verity,
265265
} => {
266266
let verity = verity_opt(config_verity)?;
267-
let (sha256, verity) =
267+
let (digest, verity) =
268268
composefs_oci::seal(&Arc::new(repo), config_name, verity.as_ref())?;
269-
println!("sha256 {}", hex::encode(sha256));
269+
println!("config {digest}");
270270
println!("verity {}", verity.to_id());
271271
}
272272
OciCommand::Mount {
@@ -367,8 +367,8 @@ async fn main() -> Result<()> {
367367
}
368368
#[cfg(feature = "http")]
369369
Command::Fetch { url, name } => {
370-
let (sha256, verity) = composefs_http::download(&url, &name, Arc::new(repo)).await?;
371-
println!("sha256 {}", hex::encode(sha256));
370+
let (digest, verity) = composefs_http::download(&url, &name, Arc::new(repo)).await?;
371+
println!("content {digest}");
372372
println!("verity {}", verity.to_hex());
373373
}
374374
}

crates/composefs-http/src/lib.rs

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
use std::{
88
collections::{HashMap, HashSet},
99
fs::File,
10-
io::Read,
1110
sync::Arc,
1211
};
1312

@@ -19,10 +18,7 @@ use sha2::{Digest, Sha256};
1918
use tokio::task::JoinSet;
2019

2120
use composefs::{
22-
fsverity::FsVerityHashValue,
23-
repository::Repository,
24-
splitstream::{DigestMapEntry, SplitStreamReader},
25-
util::Sha256Digest,
21+
fsverity::FsVerityHashValue, repository::Repository, splitstream::SplitStreamReader,
2622
};
2723

2824
struct Downloader<ObjectID: FsVerityHashValue> {
@@ -66,17 +62,11 @@ impl<ObjectID: FsVerityHashValue> Downloader<ObjectID> {
6662
}
6763
}
6864

69-
fn open_splitstream(&self, id: &ObjectID) -> Result<SplitStreamReader<File, ObjectID>> {
70-
SplitStreamReader::new(File::from(self.repo.open_object(id)?))
65+
fn open_splitstream(&self, id: &ObjectID) -> Result<SplitStreamReader<ObjectID>> {
66+
SplitStreamReader::new(File::from(self.repo.open_object(id)?), None)
7167
}
7268

73-
fn read_object(&self, id: &ObjectID) -> Result<Vec<u8>> {
74-
let mut data = vec![];
75-
File::from(self.repo.open_object(id)?).read_to_end(&mut data)?;
76-
Ok(data)
77-
}
78-
79-
async fn ensure_stream(self: &Arc<Self>, name: &str) -> Result<(Sha256Digest, ObjectID)> {
69+
async fn ensure_stream(self: &Arc<Self>, name: &str) -> Result<(String, ObjectID)> {
8070
let progress = ProgressBar::new(2); // the first object gets "ensured" twice
8171
progress.set_style(
8272
ProgressStyle::with_template(
@@ -113,8 +103,8 @@ impl<ObjectID: FsVerityHashValue> Downloader<ObjectID> {
113103

114104
// this part is fast: it only touches the header
115105
let mut reader = self.open_splitstream(&id)?;
116-
for DigestMapEntry { verity, body } in &reader.refs.map {
117-
match splitstreams.insert(verity.clone(), Some(*body)) {
106+
for (body, verity) in reader.iter_named_refs() {
107+
match splitstreams.insert(verity.clone(), Some(body.to_string())) {
118108
// This is the (normal) case if we encounter a splitstream we didn't see yet...
119109
None => {
120110
splitstreams_todo.push(verity.clone());
@@ -125,7 +115,7 @@ impl<ObjectID: FsVerityHashValue> Downloader<ObjectID> {
125115
// verify the SHA-256 content hashes later (after we get all the objects) so we
126116
// need to make sure that all referents of this stream agree on what that is.
127117
Some(Some(previous)) => {
128-
if previous != *body {
118+
if previous != body {
129119
bail!(
130120
"Splitstream with verity {verity:?} has different body hashes {} and {}",
131121
hex::encode(previous),
@@ -208,8 +198,8 @@ impl<ObjectID: FsVerityHashValue> Downloader<ObjectID> {
208198
for (id, expected_checksum) in splitstreams {
209199
let mut reader = self.open_splitstream(&id)?;
210200
let mut context = Sha256::new();
211-
reader.cat(&mut context, |id| self.read_object(id))?;
212-
let measured_checksum: Sha256Digest = context.finalize().into();
201+
reader.cat(&self.repo, &mut context)?;
202+
let measured_checksum = format!("sha256:{}", hex::encode(context.finalize()));
213203

214204
if let Some(expected) = expected_checksum {
215205
if measured_checksum != expected {
@@ -265,7 +255,7 @@ pub async fn download<ObjectID: FsVerityHashValue>(
265255
url: &str,
266256
name: &str,
267257
repo: Arc<Repository<ObjectID>>,
268-
) -> Result<(Sha256Digest, ObjectID)> {
258+
) -> Result<(String, ObjectID)> {
269259
let downloader = Arc::new(Downloader {
270260
client: Client::new(),
271261
repo,

crates/composefs-oci/src/image.rs

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@
1111
use std::{ffi::OsStr, os::unix::ffi::OsStrExt, rc::Rc};
1212

1313
use anyhow::{ensure, Context, Result};
14-
use oci_spec::image::ImageConfiguration;
14+
use sha2::{Digest, Sha256};
1515

1616
use composefs::{
1717
fsverity::FsVerityHashValue,
1818
repository::Repository,
1919
tree::{Directory, FileSystem, Inode, Leaf},
2020
};
2121

22+
use crate::skopeo::TAR_LAYER_CONTENT_TYPE;
2223
use crate::tar::{TarEntry, TarItem};
2324

2425
/// Processes a single tar entry and adds it to the filesystem.
@@ -84,21 +85,38 @@ pub fn process_entry<ObjectID: FsVerityHashValue>(
8485

8586
/// Creates a filesystem from the given OCI container. No special transformations are performed to
8687
/// make the filesystem bootable.
88+
///
89+
/// If `config_verity` is given it is used to get the OCI config splitstream by its fs-verity ID
90+
/// and the entire process is substantially faster. If it is not given, the config and layers will
91+
/// be hashed to ensure that they match their claimed blob IDs.
8792
pub fn create_filesystem<ObjectID: FsVerityHashValue>(
8893
repo: &Repository<ObjectID>,
8994
config_name: &str,
9095
config_verity: Option<&ObjectID>,
9196
) -> Result<FileSystem<ObjectID>> {
9297
let mut filesystem = FileSystem::default();
9398

94-
let mut config_stream = repo.open_stream(config_name, config_verity)?;
95-
let config = ImageConfiguration::from_reader(&mut config_stream)?;
99+
let (config, map) = crate::open_config(repo, config_name, config_verity)?;
96100

97101
for diff_id in config.rootfs().diff_ids() {
98-
let layer_sha256 = super::sha256_from_digest(diff_id)?;
99-
let layer_verity = config_stream.lookup(&layer_sha256)?;
102+
let layer_verity = map
103+
.get(diff_id.as_str())
104+
.context("OCI config splitstream missing named ref to layer {diff_id}")?;
105+
106+
if config_verity.is_none() {
107+
// We don't have any proof that the named references in the config splitstream are
108+
// trustworthy. We have no choice but to perform expensive validation of the layer
109+
// stream.
110+
let mut layer_stream =
111+
repo.open_stream("", Some(layer_verity), Some(TAR_LAYER_CONTENT_TYPE))?;
112+
let mut context = Sha256::new();
113+
layer_stream.cat(repo, &mut context)?;
114+
let content_hash = format!("sha256:{}", hex::encode(context.finalize()));
115+
ensure!(content_hash == *diff_id, "Layer has incorrect checksum");
116+
}
100117

101-
let mut layer_stream = repo.open_stream(&hex::encode(layer_sha256), Some(layer_verity))?;
118+
let mut layer_stream =
119+
repo.open_stream("", Some(layer_verity), Some(TAR_LAYER_CONTENT_TYPE))?;
102120
while let Some(entry) = crate::tar::get_entry(&mut layer_stream)? {
103121
process_entry(&mut filesystem, entry)?;
104122
}

0 commit comments

Comments
 (0)