slpc 0.4.0

Read, write, and validate Slipcase containers: a ZIP holding a content file and the TOML flyleaf that describes it
Documentation

slpc

The library half of the Rust implementation of Slipcase, a container format that binds a file to a flyleaf describing it.

A .slpc file is a ZIP archive holding a content file of any type together with a TOML flyleaf document describing it. The two become one file, so copying, moving, or sending the content file carries its flyleaf along.

Reading

fn main() -> Result<(), slpc::Error> {
    let mut c = slpc::Container::open("report.pdf.slpc")?;
    println!("{} holds {}", c.version(), c.content_name());
    std::io::copy(&mut c.content()?, &mut std::io::stdout())?;
    Ok(())
}

The content file is a stream and is never read into memory; content_size reports how long it is without decompressing any of it, and borrows shared so the name and the size can be asked for in one expression. The flyleaf is exposed as a document that keeps comments, key order, and whitespace across a rewrite, and as the member's bytes for a caller who wants a different parser or a hash.

Writing

Nothing here takes or returns a container. Each of these reads a stream and writes a stream, so a rewrite cannot accidentally hold a content file in memory.

use slpc::toml_edit::DocumentMut;

fn main() -> Result<(), slpc::Error> {
    let out = std::fs::File::create("report.pdf.slpc")?;
    slpc::pack_file("report.pdf", DocumentMut::new(), out)?;
    Ok(())
}

The flyleaf argument is anything convertible into a DocumentMut, which is a document, a table, or, with toml_edit's serde feature turned on by the caller, whatever toml_edit::ser::to_document makes of a struct or a map. Building a flyleaf from nothing has no formatting to preserve, so there is nothing for that conversion to lose.

pack_reader takes its content from any Read and needs no Seek at either end, so a container can be packed from a pipe into a socket.

Changing one that already exists

Repack replaces the flyleaf, the content file, or both. Every other member is copied through as stored bytes, in the order it arrived in, whether or not this build can decompress it — which is what the specification requires of an implementation rewriting a container, and what lets one be changed without being fully understood.

fn main() -> Result<(), slpc::Error> {
    let source = std::fs::File::open("report.pdf.slpc")?;
    let out = std::fs::File::create("report-v2.pdf.slpc")?;

    slpc::Repack::new(source)
        .content_file("report-v2.pdf")?
        .write(out)?;
    Ok(())
}

The source and the destination both seek: a ZIP's central directory is at the end of the file, and a member copied through already knows its compressed size, which belongs in the header rather than in a promise of one to come. Packing has neither constraint and keeps its Write-only destination.

A content file written under a new name carries content.file with it. A content file written under the name the container already used leaves the flyleaf member alone, byte for byte. rewrite_flyleaf and rewrite_flyleaf_bytes are the flyleaf-only case, which should not need a builder to say.

Everything on the way out is checked against the rules the read path reads by, so what this writes is what it would accept back.

Putting one on disk

Everything above writes into a stream the caller supplies. Turning on the fs feature adds Destination, which writes a container to a path: through a temporary file beside it, with the permissions a file there should have, renamed into place at the end. A write that fails partway leaves nothing behind rather than a truncated container that looks like one.

slpc = { version = "0.4", features = ["fs"] }

It is a feature rather than part of the default surface because a caller writing into a socket or a buffer should not acquire a temporary-file dependency to do it.

content_path comes with it, and a caller taking a content file out of a container should use it rather than joining the name to a directory. A name legal under the specification is not always a file: Windows resolves CON, COM1, AUX and a handful of others to devices wherever the name appears, so dir.join(name) is the console rather than a path in dir. It is not a traversal and the check against SPEC 2.3 does not catch it. display_path is the other half, for showing somebody where their content file went.

Where a container came from

A container downloaded from the internet is marked as such by the platform that downloaded it, and the mark is a property of the file rather than of its contents — so a content file written out of that container carries nothing unless something puts it there. Without that, unpacking is laundering: the content file reaches whatever opens it next as a file this machine made, and the warning the platform would have raised never appears.

slpc = { version = "0.4", features = ["fs", "provenance"] }

provenance::carry moves it across — com.apple.quarantine on macOS, a Zone.Identifier stream on Windows, user.xdg.origin.url on Linux. It fails only where the platform gates opening on a mark, the source carries one, and the copy ends up carrying none, so the whole of the rule for a caller about to hand a content file to the system is that an error means do not open it.

use slpc::provenance::{carry, Mark};

fn main() -> Result<(), slpc::Error> {
    // The content file has just been written out of the container.
    match carry("report.pdf.slpc".as_ref(), "report.pdf".as_ref())? {
        Mark::Silent => {}                    // the container arrived from nowhere
        Mark::Noted => {}                     // carried, but nothing here consults it
        _ => {}                               // carried, and the platform will read it
    }
    Ok(())
}

Off by default, and separate from fs: a caller writing containers has no use for it, and one unpacking only their own does not need it either. On Windows it adds nothing at all. On Unix it adds one crate on top of fs, or four without it, fs already carrying most of what xattr needs.

Validating

fn main() -> Result<(), slpc::Error> {
    match slpc::validate(std::fs::File::open("report.pdf.slpc")?)? {
        slpc::Verdict::Conformant => println!("conformant"),
        other => println!("{other}"),
    }
    Ok(())
}

Four verdicts rather than two. A container whose flyleaf member cannot be read is neither conformant nor non-conformant, and one declaring a version this build does not implement is outside the question rather than failing it.

A container can fail elsewhere and still carry a flyleaf document worth reading — content.file naming no member leaves one that parsed cleanly — so flyleaf_of returns that document and asks no conformance question. It is not a verdict and says nothing about whether the container conforms; validate is the only function here that answers that.

No vocabulary

The two structural keys have typed accessors. Every other key is passed through unexamined, because there is nothing to examine it against.

The specification lives in excelano/slipcase and is the authority on the format. https://slipcaseformat.org publishes it as pages.

This crate implements it and has no standing to change it. The command-line tool built on it is slipcase, in the same repository.

License

MIT. See LICENSE.