Documentation
¶
Overview ¶
Package warc reads and writes WARC files (ISO 28500), the format a web crawl is archived in, in pure Go with no cgo and no dependency outside github.com/go-filesystems/interface.
Three entry points, one per way a caller wants the file:
func NewReader(r io.Reader) (*Reader, error) // stream records func NewWriter(w io.Writer) *Writer // write records func OpenReader(r io.ReaderAt, size int64) (filesystem.Filesystem, error) // one entry per record
Open is OpenReader with the concrete *FS, for the three calls that are not on the interface: Names, Header and Record.
The format, and the two places a reader gets it wrong ¶
A record is
WARC/1.1 CRLF Header-Name: value CRLF (repeated) CRLF <exactly Content-Length octets> CRLF CRLF
The version line, the field names and every CRLF are ASCII; Content-Length is mandatory and counts octets. WARC-Type, WARC-Date (ISO 8601, UTC) and WARC-Record-ID (a URI in angle brackets) are mandatory too.
Two properties of that framing are where implementations fail, and this package is built around both:
THE TWO TRAILING CRLFS ARE PART OF THE RECORD, not a separator to be skipped. A reader that consumes whitespace until the next "WARC/" instead of reading exactly four octets accepts a truncated record as a whole one, and mis-frames the file whenever a block ends in a newline -- which, for a response record holding an HTTP message, is the usual case rather than the odd one. This package reads four octets and compares them.
THE BLOCK IS OPAQUE. It may contain CRLF, NUL, a lone CR, bytes that are not text; nothing may scan it for a delimiter. This package reads exactly Content-Length octets and never looks inside. It also does not ALLOCATE Content-Length octets up front: the length comes from the file, so a seventy-octet header claiming an eight-exabyte block would otherwise be an out-of-memory panic rather than an error.
Plain .warc only ¶
A .warc.gz is not a gzipped .warc: it is a concatenation of one gzip MEMBER per record, so that an index can seek to a record and inflate that record alone. This package handles plain .warc. A caller with a single-member stream wraps it:
zr, err := gzip.NewReader(f) r, err := warc.NewReader(zr)
In this family that is not a gap in practice: github.com/go-filesystems/unarchive peels compression before it dispatches on format, so a .warc.gz arrives here already inflated. It IS a gap for a caller that wants per-record random access into a .warc.gz, because that needs the gzip member offsets, which this package does not compute.
The filesystem view, and how entries are named ¶
OpenReader presents the file as a flat, read-only directory with one regular file per record, whose contents are that record's content block. Opening reads only the header blocks; a block is read when a caller asks for it, which is why FS implements the optional filesystem.Opener and can answer a byte range out of a hundred-megabyte block without materialising it. Every mutating method returns ErrReadOnly.
An entry is named
<5-digit ordinal>-<WARC-Type>[-<sanitised tail of WARC-Target-URI>] 00000-warcinfo 00001-response-one.html 00002-request-one.html 00003-resource-blob.bin
WARC-Record-ID is the obvious unique key and it is the wrong filename. Its value is a URI in angle brackets -- "<urn:uuid:5a3b...>" in practice -- whose brackets and colons are illegal in a Windows filename and whose colon is the HFS path separator, so it has to be mangled; what survives the mangling is a UUID, which tells a person nothing and which they cannot predict from the archive in front of them. The target URI alone is worse, because it is not unique: a crawl of one page writes a request AND a response record under the same WARC-Target-URI, and a revisit record later under the same one again, so three entries would silently become one.
The ordinal is the record's position in the file. It is unique by construction, so no two entries can collide whatever the headers say; it sorts the listing into crawl order under a plain lexical sort; and a person can count to it. It is NOT stable across files -- record 5 of a re-fetched crawl is a different record, and concatenating two WARCs renumbers the second -- and that is the price. A caller that needs an identity across files wants WARC-Record-ID, which is what it is for, and Record(name).RecordID() hands it over.
What it refuses ¶
The reader refuses a version line other than WARC/1.0 or WARC/1.1, a line not terminated by CRLF, a header line with no colon or a field name that is not a token, a Content-Length that is absent, repeated or not a decimal octet count, a missing WARC-Type, WARC-Date or WARC-Record-ID, a WARC-Date that is not a UTC timestamp, a record ID without angle brackets, and a trailer that is not two CRLFs. All of those satisfy errors.Is(err, ErrSyntax); a truncated file satisfies errors.Is(err, io.ErrUnexpectedEOF); an I/O failure is neither, so a caller can tell "this is not a WARC" from "the disk went away".
It does NOT refuse an unregistered WARC-Type. §5.5 tells a reader to pass an unknown record type through rather than treat the file as broken, so refusing one would reject files a conforming writer may produce.
The writer refuses rather than repairs, because each case it refuses would read back as something other than what the caller passed: a missing mandatory field, a field name that is not a token, a value containing CR, LF or NUL -- that one is header injection, and the writer is the only place it can be stopped -- a value with a leading or trailing SP or HT, which a reader is entitled to strip, and a Content-Length disagreeing with len(Block).
⚠ WHERE THE FIXTURES CAME FROM, AND WHAT THAT COSTS IN CONFIDENCE ¶
Neither wget nor warcio was installed on the machine this package was written on, so there was no crawl to point at and no archive to hand. The corpus is therefore two files, and the reader should know which is which:
- testdata/handbuilt.warc is OUR OWN CONSTRUCTION. Its octets were laid out from §5's grammar by testdata/gen_handbuilt.py, a script that writes CRLFs with string concatenation. It cannot fail the way a real archive can: it contains no crawl's idiosyncrasies, no truncated fetch, no header a real crawler emits that we did not think to write.
- testdata/warcio.warc was written by warcio (Apache-2.0), the Python implementation behind Webrecorder and the Common Crawl tooling, installed into a throwaway virtualenv for the purpose. Its framing is not ours. Its payloads are, so committing it redistributes nothing third-party.
No specimen from a public archive is committed: what is freely downloadable is not automatically redistributable, and a licence we could not establish is a licence we do not act on.
What compensates for that, and what does not:
- A round trip in BOTH directions. Reader∘Writer and Writer∘Reader are both tested, and that is the weaker half: two halves of one package agree with each other about a misunderstanding every time.
- Bytes laid out by hand from the specification and read back. This is what stops the two halves agreeing privately, and it is why testdata/handbuilt.warc is generated by a script rather than by the Writer.
- Byte equality with warcio. TestWriterReproducesWarcio reads testdata/warcio.warc and re-emits it; the result must be identical, octet for octet. It is. So our Writer puts every CRLF where an implementation we did not write put it.
- Measured off-repository, not in CI, because warcio cannot be a CI dependency: warcio reads both fixtures and both of our re-emissions, 7 and 4 records, every block the declared length. It agrees with us on the record count, the types, the block lengths, the three repeated WARC-Concurrent-To values and on splitting "X-Note: a:b:c: d" at the FIRST colon.
The one place warcio and this package disagree is worth stating rather than hiding. For a folded field value
X-Folded: first CRLF HT second CRLF SP SP SP SP third
this package yields "first second third" and warcio yields "first\tsecond third". RFC 2616 §2.2, which ISO 28500 §4 takes LWS from, says a recipient MAY replace linear white space with a single SP before interpreting a value, so both readings conform. This package normalises, because a value's meaning should not depend on where a writer chose to break the line; a caller that needs the octets as they lay on disk should not be reading them through a field value at all.
Fixtures are EMBEDDED with //go:embed rather than read from testdata/ at run time, because this repository's cross-architecture lanes build the test binary with `go test -c` and run it in a container holding the binary and nothing else. A test that read testdata/ would pass on the four native lanes and fail on the four emulated ones.
Index ¶
- Constants
- Variables
- func EntryName(ordinal int, warcType, targetURI string) string
- func OpenReader(r io.ReaderAt, size int64) (filesystem.Filesystem, error)
- type FS
- func (f *FS) Close() error
- func (f *FS) DeleteDir(p string) error
- func (f *FS) DeleteFile(p string) error
- func (f *FS) Header(name string) (Header, error)
- func (f *FS) ListDir(p string) ([]filesystem.DirEntry, error)
- func (f *FS) MkDir(p string, perm os.FileMode) error
- func (f *FS) Names() []string
- func (f *FS) OpenFile(p string) (filesystem.File, error)
- func (f *FS) ReadFile(p string) ([]byte, error)
- func (f *FS) ReadLink(p string) (string, error)
- func (f *FS) Record(name string) (*Record, error)
- func (f *FS) Rename(oldPath, newPath string) error
- func (f *FS) Stat(p string) (filesystem.Stat, error)
- func (f *FS) WriteFile(p string, data []byte, perm os.FileMode) error
- type Field
- type File
- type Header
- type Reader
- type Record
- type Writer
Constants ¶
const ( // MaxHeaderBytes bounds the version line and every named field of one // record together, lines terminators included. MaxHeaderBytes = 1 << 20 // DefaultMaxBlockSize bounds a block held in memory. DefaultMaxBlockSize = 1 << 30 )
The ceilings. A WARC file is untrusted input, and every one of these is a number the file alone could otherwise choose.
ISO 28500 (the 1.1 text, checked) states no limit on a header block, and jwarc's parser keeps none either; MaxHeaderBytes is net/http's DefaultMaxHeaderBytes (1 MiB, net/http/server.go), the same kind of thing: a block of "Name: value" lines ended by an empty line. DefaultMaxBlockSize is a policy, not a format fact, chosen so that a block always fits an int on a 32-bit target; a block is held whole by Next, ReadFile and Record. OpenFile has no such limit, since it reads nothing until asked.
const ( Version10 = "WARC/1.0" Version11 = "WARC/1.1" )
The two WARC versions this package reads and writes. ISO 28500:2009 defined 1.0; ISO 28500:2017 defined 1.1, whose only framing-relevant change is that WARC-Date may carry fractional seconds. Anything else in the version line is refused rather than guessed at, because a reader that accepts a version it does not know cannot promise it framed the file correctly.
const ( TypeWarcinfo = "warcinfo" TypeResponse = "response" TypeRequest = "request" TypeMetadata = "metadata" TypeResource = "resource" TypeRevisit = "revisit" TypeConversion = "conversion" TypeContinuation = "continuation" )
The WARC-Type values ISO 28500 defines. A reader must NOT refuse a type it does not know -- §5.5 says an unknown record type is to be skipped, not treated as a broken file -- so these are here for writers and for callers that switch on the type, and the reader never consults them.
const ( HeaderType = "WARC-Type" HeaderDate = "WARC-Date" HeaderRecordID = "WARC-Record-ID" HeaderContentLength = "Content-Length" HeaderConcurrentTo = "WARC-Concurrent-To" HeaderContentType = "Content-Type" HeaderBlockDigest = "WARC-Block-Digest" HeaderPayloadDigest = "WARC-Payload-Digest" HeaderIPAddress = "WARC-IP-Address" HeaderRefersTo = "WARC-Refers-To" HeaderTargetURI = "WARC-Target-URI" HeaderTruncated = "WARC-Truncated" HeaderWarcinfoID = "WARC-Warcinfo-ID" HeaderFilename = "WARC-Filename" HeaderProfile = "WARC-Profile" HeaderIdentifiedPLD = "WARC-Identified-Payload-Type" HeaderSegmentNumber = "WARC-Segment-Number" HeaderSegmentOriginI = "WARC-Segment-Origin-ID" HeaderSegmentTotal = "WARC-Segment-Total-Length" )
Named fields this package refers to by name. The four mandatory ones are listed first; the rest are here so callers do not have to spell them.
Variables ¶
var ErrReadOnly = errors.New("warc: filesystem is read-only")
ErrReadOnly is returned by every mutating method of the filesystem view. A WARC file is an append-only log of what a crawl saw; rewriting a record in place would change a record of fact, so the Filesystem surface refuses it rather than implementing half of it.
var ErrSyntax = errors.New("warc: syntax")
ErrSyntax is the class of every failure to frame or parse a WARC file: a version line that is not one, a header line without a colon, a Content-Length that is not a decimal count of octets, a record whose two trailing CRLFs are not there. Callers distinguish "this is not a WARC" from an I/O failure with errors.Is(err, warc.ErrSyntax); the wrapped message says which record and what was wrong.
var ErrTooLarge = errors.New("warc: over the size ceiling")
ErrTooLarge is returned when a record asks for more memory than the ceilings above allow. It is not a syntax error: the file may be a well-formed WARC that this reader declines to hold.
Functions ¶
func EntryName ¶
EntryName is the name record ordinal (0-based) gets in the filesystem view:
00000-warcinfo 00001-request-index.html 00002-response-index.html 00003-resource-logo.png
Why not WARC-Record-ID ¶
The obvious unique key is WARC-Record-ID, and it is the wrong filename. Its value is a URI in angle brackets, in practice "<urn:uuid:5a3b...>": the angle brackets and the colons are not allowed in a Windows filename and the colon is the HFS path separator, so the name has to be mangled anyway; what is left is a UUID, which tells a person nothing and which they cannot predict from the archive they are looking at. A tool that wants the record ID can ask for it -- Record(name).RecordID() -- and a person reading a directory listing gains nothing from seeing thirty UUIDs.
The target URI alone is worse: it is not unique. A crawl of one page writes a request AND a response record with the same WARC-Target-URI, and a revisit record later with the same one again, so the names would collide -- silently, three entries becoming one.
Why ordinal first ¶
The ordinal is the record's position in the file. It is unique by construction, so no two entries can collide whatever the headers say; it sorts the listing into archive order, which is crawl order, under a plain lexical sort; and it is stable for a given file, which a hash of the record would also be but an ordinal is one a person can count to.
It is NOT stable across files: record 5 of a WARC is not record 5 of the same crawl re-fetched, and concatenating two WARCs renumbers the second. That is the cost, and it is the right one to pay here -- a filesystem view of one file needs names unique within that file, and a caller that needs an identity across files has WARC-Record-ID, which is exactly what it is for.
The type and the URI tail follow so the name says what the entry is. Both are sanitised to ASCII letters, digits, '.', '_' and '-' and truncated, so the name is safe on every filesystem this family writes to; two records whose tails sanitise to the same string still differ by ordinal.
func OpenReader ¶
func OpenReader(r io.ReaderAt, size int64) (filesystem.Filesystem, error)
OpenReader opens the WARC file in the first size bytes of r and returns it as a filesystem.Filesystem.
It exists so every driver in go-filesystems answers to ONE name for the same thing: github.com/go-filesystems/detect registers a func(io.ReaderAt, int64) (filesystem.Filesystem, error), and Open here has the right shape but the concrete return type, which does not fit. The concrete type is still available from Open for a caller that wants Record, Header or Names.
Types ¶
type FS ¶
type FS struct {
// contains filtered or unexported fields
}
FS is a WARC file seen as a read-only filesystem: a flat directory with one regular file per record, whose contents are that record's content block.
Contents are the BLOCK, and the header is beside it ¶
Reading an entry gives the record's content block, not the record's bytes. That is the choice a caller of a filesystem wants -- the archived HTTP message, the fetched resource -- and it is what makes Stat's size the number ReadFile returns. The named fields are not lost: Record and Header on this type hand them back for any entry, and Reader gives both together for a caller that wants to stream.
Every mutating method returns ErrReadOnly ¶
A WARC is a record of what a crawl observed, and the format has no way to rewrite a record in place: Content-Length fixes each record's extent, so changing one byte of a block either changes the length -- moving every later record -- or is not the record that was written. So the write half of filesystem.Filesystem returns ErrReadOnly rather than a partial implementation.
func Open ¶
Open reads the record headers of the WARC file in the first size bytes of r and returns it as a filesystem. Every record is framed on the way through, so Open fails on a file that is not a WARC rather than on the read that reaches the bad record.
Blocks are not read. Open costs one pass over the header blocks plus a seek per record; ReadFile and OpenFile read a block when asked.
func (*FS) Close ¶
Close releases nothing: FS does not own the io.ReaderAt it was handed, and closing what a caller passed in would be closing something it may still be using. It is here because filesystem.Filesystem has it.
func (*FS) DeleteFile ¶
DeleteFile always returns ErrReadOnly.
func (*FS) Header ¶
Header returns the named fields of the record behind an entry, in file order. The returned Header is a copy.
func (*FS) ListDir ¶
func (f *FS) ListDir(p string) ([]filesystem.DirEntry, error)
ListDir lists the root, the only directory this filesystem has.
func (*FS) OpenFile ¶
func (f *FS) OpenFile(p string) (filesystem.File, error)
OpenFile opens a record's content block for random access, which is the reason this driver implements filesystem.Opener: a response record's block can be a hundred megabytes, and a server answering a range request out of a WARC must not have to materialise all of it. Opening reads nothing.
func (*FS) ReadLink ¶
ReadLink always fails: a WARC has no symbolic links. A path that is not there is still reported as not there, so a caller can tell the two apart.
func (*FS) Record ¶
Record returns the whole record behind an entry: version, headers and block. The block is read now.
type Field ¶
Field is one WARC named field: a name and its value, in the order the record carries them.
Fields are a SLICE and not a map because two properties of the format make a map wrong. A WARC record may repeat a field name -- WARC-Concurrent-To is the ordinary case, a record concurrent with several others -- and a map either loses the repeats or has to model them anyway. And a WARC file is evidence: a reader that reorders the header block cannot hand a caller the bytes it read, so a digest computed over a re-serialised header would not match the one the crawler wrote.
type File ¶
type File struct {
// contains filtered or unexported fields
}
File is an open content block: random access over the record's extent in the underlying file, and its size.
ReadAt is io.SectionReader's, so it keeps io.ReaderAt's contract to the letter -- a short read always carries an error, an offset at or past the end gives (0, io.EOF), and concurrent calls are safe -- rather than this package reimplementing it and getting one of those wrong.
type Header ¶
type Header []Field
Header is a record's named fields, in file order.
func (*Header) Add ¶
Add appends a field, keeping any field of the same name that is already there. This is the call for WARC-Concurrent-To and the other repeatable fields.
func (Header) Clone ¶
Clone returns a copy that shares no storage with h, so a caller can keep a record's header after handing the record on.
func (Header) Get ¶
Get returns the value of the first field with this name, comparing names case-insensitively as the grammar's token production requires, or "" if there is none. Use Values when a name may repeat: Get cannot tell one absent field from one whose value is empty, and Has can.
func (Header) Has ¶
Has reports whether the name is present at all, which is not the same question as Get returning a non-empty string.
type Reader ¶
type Reader struct {
// MaxBlockSize is the largest Content-Length Next will read into memory;
// zero means DefaultMaxBlockSize. A larger declared length is refused with
// ErrTooLarge BEFORE a byte of the block is read. Set it before the first
// Next.
MaxBlockSize int64
// contains filtered or unexported fields
}
Reader reads WARC records from a stream, one at a time, in file order.
What it does not do: gzip ¶
A .warc.gz is not a gzipped .warc. It is a concatenation of one gzip MEMBER per record, so that an index can seek to a record and inflate it alone; the deflate streams are per-record and the file is only valid gzip by the multi-member rule. This package handles PLAIN .warc and nothing else. Wrap the stream yourself for the single-member case:
zr, err := gzip.NewReader(f) // and zr.Multistream(false) per member r, err := warc.NewReader(zr)
In this family it is not a gap in practice: github.com/go-filesystems/unarchive peels compression before it dispatches on format, so a .warc.gz reaches this package already inflated. It IS a gap for a caller that wants per-record random access into a .warc.gz, which needs the member offsets this package does not compute.
func NewReader ¶
NewReader returns a Reader that reads records from r.
It returns an error only for a nil reader. The signature keeps the error because a caller writing `r, err := warc.NewReader(f)` should not have to change shape if a future version validates something up front, and because a nil io.Reader here would otherwise panic on the first Next instead of at the call that was wrong.
func (*Reader) Next ¶
Next returns the next record, or io.EOF at a clean end of file -- that is, immediately after a record's second CRLF with nothing following.
The returned Record owns its Block; the Reader keeps no reference to it, so a caller may retain records across calls.
Errors are sticky. A malformed record leaves the stream at an offset this package cannot justify resuming from -- the whole point of Content-Length framing is that there is no resynchronisation point to scan for -- so every later Next returns the same error rather than pretending to recover.
type Record ¶
type Record struct {
// Version is the version line, e.g. Version11. NewWriter's WriteRecord
// treats "" as Version11.
Version string
// Header is the named fields, in order.
Header Header
// Block is the content block, Content-Length octets exactly.
Block []byte
}
Record is one WARC record: its version line, its named fields in file order, and its content block.
Block is the block EXACTLY as the file carries it, Content-Length octets of it, with nothing trimmed. The block is opaque: a response record's block is an HTTP message, a warcinfo block is an application/warc-fields document, a resource block is whatever was fetched, and any of them may contain CRLF, NUL or bytes that are not text at all. Nothing in this package looks inside it.
func (*Record) ContentType ¶
ContentType returns the block's Content-Type, or "" if unstated.
func (*Record) Date ¶
Date parses WARC-Date. The reader has already checked it parses, so the error here is for a Record a caller built.
func (*Record) RecordID ¶
RecordID returns WARC-Record-ID as the file carries it, angle brackets and all: the field's value is a URI in angle brackets, and stripping them here would make the value we hand back differ from the value we read.
type Writer ¶
type Writer struct {
// contains filtered or unexported fields
}
Writer writes WARC records to a stream.
It writes PLAIN .warc. A .warc.gz is one gzip member per record, which a caller composes by wrapping each WriteRecord in its own gzip.Writer and closing it before the next; this package does not do it, because doing it halfway -- one gzip stream around the whole file -- produces a file that inflates to a valid WARC and is NOT a valid .warc.gz, since no index can seek into it.
func NewWriter ¶
NewWriter returns a Writer that writes records to w. It does not buffer: each WriteRecord issues three Writes (header block, content block, trailer), so a caller writing many small records should hand it a bufio.Writer.
func (*Writer) WriteRecord ¶
WriteRecord writes one record: version line, named fields, CRLF, the block's octets, CRLF, CRLF.
It REFUSES rather than repairs, in four cases, because each one is a record that would read back as something other than what the caller passed:
- a missing WARC-Type, WARC-Date or WARC-Record-ID: §5 makes them mandatory, and this package's own Reader would refuse the result.
- a field name that is not a token, or a value containing CR, LF or NUL: a value with a CRLF in it does not produce a long header, it produces two, and the second is whatever the caller's data happened to say. That is the header-injection shape, and a writer is the only place it can be stopped.
- a value with leading or trailing space or tab: the grammar lets a reader strip it, so writing it would silently change the value. Trim it first and the round trip is exact.
- a Content-Length that disagrees with len(Block): one of the two is wrong and the writer cannot know which. A record with no Content-Length gets one, computed from the block, appended after the caller's fields.
Errors are sticky: after a failed write the stream's contents are no longer a WARC, so further records are not appended to it.
