Two things are generated from the repository tree, and both live here:
gen_pages.pybuilds the websitegen_zip.pybuilds the lab archives handed to students
Both ask sessions.py what a section, a session and an exercise are, so the site and the archives can never disagree about it.
Two more generate things a session needs before the site can be built, and are run from the Pages workflow:
render_slides.shrenders the Quarto decks undercontent/**/slides/gen_media.shconverts the diagram exports undercontent/**/media/
A fifth script, check-prerequisites.sh, generates nothing: it checks that the tools a session needs are installed.
Everything published lives under content/, and the tree there is the whole specification:
content/
├── lectures/
│ ├── 01-software-stack-live/ the plan of the lecture, as one page
│ └── 01-software-stack-full/ the full lecture, with a directory per part
├── labs/
│ ├── 01-software-stack-live/ what students work on during the lab
│ │ ├── demo-printf-vs-write/
│ │ └── 01-string-functions/
│ └── 01-software-stack-full/ the reference solutions and tutorials
├── assignments/
└── extra/
- A section is a directory directly below
content/:lectures,labs,assignments,extra. Each becomes a path of the website, and a new one appears simply by creating the directory and writing itsREADME.md.SECTION_ORDERinsessions.pysays which order they are shown in; a section not named there comes after those, by name. - A session is a directory directly below a section, and comes in two halves:
NN-<name>-live/andNN-<name>-full/.-liveis what is used while the session runs — for a lab the skeletons and task descriptions, for a lecture the one-page plan of what it covers.-fullis the version written to be read afterwards. The halves are published in separate views, so which one a page belongs to is decided by this suffix. A directory below a section with neither suffix, such as one underassignments/, is a session of its own with no halves, and belongs to neither view. - An exercise is any directory below a session that contains a
README.md, nested ones included. A lecture's-fullhalf holds exercises exactly the way a lab's does, and is rendered exactly the same way; a-livelecture that is only a one-pager is simply a session with nothing below it, and gains its subdirectories as soon as they are written.
The repository is published as a static website through GitHub Pages.
The site is built with MkDocs and the Material theme, and deployed by the .github/workflows/pages.yml workflow on every push to master.
The site is not one tree but two. A session is written in two halves, and each half is published in a view of its own, so the two are never listed side by side: the live view lists only live material, and the full view only full material. The view is the first part of the URL, which is why the variant suffix a directory carries is not repeated in it.
| Directory | Page |
|---|---|
content/labs/01-software-stack-live/ |
/live/labs/01-software-stack/ |
content/labs/01-software-stack-live/01-string-functions/ |
/live/labs/01-software-stack/01-string-functions/ |
content/labs/01-software-stack-full/ |
/full/labs/01-software-stack/ |
content/lectures/03-memory-live/ |
/live/lectures/03-memory/ |
content/lectures/03-memory-full/ |
/full/lectures/03-memory/ |
content/assignments/ |
/assignments/ |
navigation.tabs in mkdocs.yml is what makes a view a view: each is a top-level entry of the navigation, so each becomes a tab, and the sidebar of a page shows only the tab it is in.
The tab bar is the one place both views appear, because it is how you switch between them.
A section with no halves — assignments/, extra/ — is part of neither view and stays at the top level, as its own tab.
A view appears only once something is written for it, so a repository with no -full/ session anywhere would have no full view rather than an empty one.
site_tree() in sessions.py is what does the splitting.
A README may still link across the views on purpose: each -full/ session README points at its -live/ half in prose, and that link is rewritten to the other view.
What the split governs is the navigation and the generated contents lists, not a deliberate cross-reference.
/ the front page, from the repository README
/info/how-the-lab-works/ the guide for students
/live/ what the live view holds
/live/labs/ content/labs/README.md
/live/labs/01-software-stack/ content/labs/01-software-stack-live/README.md
/live/labs/01-software-stack/01-string-functions/
/live/lectures/01-software-stack/ the plan of that lecture
/full/labs/01-software-stack/ content/labs/01-software-stack-full/README.md
/full/lectures/01-software-stack/ the full lecture, with its own subpages
/assignments/ a section with no halves
A section README.md is published once per view it has sessions in, so content/labs/README.md is the page behind both /live/labs/ and /full/labs/; the list of sessions generated below it differs, because it names only the sessions of that view.
There are no pages stored in this repository.
gen_pages.py discovers everything at build time, by walking content/, and hands the pages to MkDocs through the mkdocs-gen-files plugin.
The navigation sidebar is generated the same way, as a SUMMARY.md read back by the mkdocs-literate-nav plugin.
- Directories named
solutionsare skipped, together with.git,.github,docs,scriptsandsite, and so is anything whose name starts with a dot. The list is theEXCLUDED_DIRSset insessions.py.utilsis on it as well: support code vendored inside an exercise, such as thebonus-printf/utils/printf/tree, is packed with its exercise but is not an exercise itself, so it gets no page and no navigation entry. - The title shown for a page is the first level-one heading of its
README.md. If the file has no heading, the directory name is used instead, and the heading is added to the page. - A session is listed under its index and the title of its
README.md, not under its directory name:01-software-stack-live/, whose README opens with# Session 01: The Software Stack, is listed as01: The Software Stack. The index keeps the sessions in the order they are taken in. A trailing— Full Contentsin a README title is dropped, because the view a page is in already says which half it is. The rules aresession_heading()andsession_title()insessions.py. - A section or session
README.mdthat is still only a title — no level-two heading anywhere in it — gets a generated list of what is below it appended, so the page is never a dead end. One that has been written is left exactly as it is, because whoever wrote it listed its contents the way they wanted them listed; this is why the lab session pages show their own exercise table rather than a generated list. - Links between READMEs, such as
../demo-puts-write, are rewritten to point at the generated pages. The site is not shaped like the directory tree, so this is done by looking the target up in the map of published pages rather than by keeping the link as written; a link that crosses into the other view is rewritten to that view. Links to files that have no page of their own, such ascopy_file.c, are sent to the file on GitHub. - Lists are re-indented, by
normalize_lists(). The READMEs follow CommonMark, as GitHub renders it: what belongs to a list item is indented to where its text starts, two spaces under*and three under1.. Python-Markdown, which MkDocs uses, needs four, and without them a code block or a nested list inside an item ends the list — the numbering restarts at 1 after every code block, and nested items show up as literal*. The page is therefore written with each item's content moved to four spaces past its marker (fenced blocks moved with it), and with a blank line before an item that directly follows a paragraph or a code block. The sources stay as they are; keep writing them for GitHub and markdownlint.
Adding a section, a session or an exercise requires no change to these scripts: create the directory, write its README.md and push.
Which view it lands in follows from the -live / -full suffix of the session it is under.
The README.md files need no front matter and no other metadata.
A new exercise appears in the navigation sidebar automatically; to list it on a written session page as well, add a row to that session's exercise table.
The docs/ directory only exists because MkDocs insists on one; every page is generated.
Install the dependencies, then build the site into _site/:
pip install -r dev/requirements.txt
mkdocs buildBetter, while writing: serve the site at http://localhost:8000 and rebuild on every change.
mkdocs serveAdd --strict to mkdocs build to turn warnings, such as a link that points nowhere, into a failed build.
The workflow deploys the site, but the repository has to allow it first.
In the repository settings, under Pages, set Source to GitHub Actions.
The site is then published at https://<owner>.github.io/<repository>/.
A session brings more than its READMEs: the figures a page shows, and the slides a lecture is delivered from.
gen_pages.py mirrors the tree below a session into the site, copying every file whose suffix is in ASSET_SUFFIXES (sessions.py) and leaving everything else alone.
Because the mirror keeps the paths, a relative link in a README already points at the right place and is not rewritten:
| In the repository | On the site |
|---|---|
content/lectures/01-software-stack-full/media/03-kernel/libc.svg |
/full/lectures/01-software-stack/media/03-kernel/libc.svg |
content/lectures/01-software-stack-full/slides/software-stack.html |
/full/lectures/01-software-stack/slides/software-stack.html |
A C source, a Makefile or a compiled binary is not an asset: it has no page, so a link to it is sent to GitHub as before.
Directories the tree walk skips — old/, utils/, anything beginning with a dot — keep their files off the site as well.
The diagrams are line drawings on a transparent ground, so docs/stylesheets/extra.css gives them a white plate in the dark colour scheme.
That file is the only hand-written thing under docs/.
A session's figures live in media/, one directory per part of the session.
Drawings made in Excalidraw are exported as PDF, which is what is committed; a browser will not show a PDF through an ![...](), so gen_media.sh converts each one to an SVG of the same name in lower case, and it is the SVG the READMEs and the slides point at.
./scripts/gen_media.sh # needs pdftocairo, from poppler-utilsThe SVGs are committed next to the PDFs, so building the site needs no conversion tools.
Re-run the script after re-exporting a drawing; it skips whatever is already newer than its source.
Diagrams made in draw.io keep their .drawio source in the same directory, and are exported to SVG from the editor.
A lecture's deck lives in slides/, written in Quarto and rendered to reveal.js.
Each deck sets embed-resources: true, so one .qmd becomes one self-contained .html with the diagrams and reveal.js inlined — which is what makes a deck publishable as a single asset.
./scripts/render_slides.sh # every deck
./scripts/render_slides.sh content/lectures/01-software-stack-live # just these
make -C content/lectures/01-software-stack-live/slides # while writing oneThe Pages workflow runs render_slides.sh before mkdocs build, so whatever is on disk when gen_pages.py walks content/ is what gets published.
Rendered decks are generated and are not committed; .gitignore covers them.
make pdf inside a slides/ directory renders the same deck through beamer, for the PDF that is published elsewhere.
That needs LaTeX (quarto install tinytex is the least painful) and rsvg-convert, from librsvg2-bin, which is what turns the SVG figures into something LaTeX can include.
Nothing is registered anywhere: a new deck is rendered and published because it is in a slides/ directory, the same way a new exercise appears because it has a README.md.
check-prerequisites.sh checks a machine against the Prerequisites and required tools section of the session READMEs, and says what to install for whatever is missing.
Students run it before a session; it is also the quickest way to tell whether a lab machine has been set up.
./scripts/check-prerequisites.sh # every session
./scripts/check-prerequisites.sh -q # only what is missingThe script only looks around, it installs nothing and changes nothing. It exits 0 when everything the checked sessions need is there, and 1 otherwise, so it can also be used in a setup check on a lab machine.
- Tools needed only by a bonus exercise are reported apart, and do not fail the run.
- Three checks are not a plain lookup of a command: the machine is x86-64, where session 02 and session 05 need it;
gcc -staticlinks, which session 01 needs and which not every distribution installs by default; and pwntools imports in thepython3that will run the session 05 exploits. - The install command is written for the package manager the machine has, out of
apt-get,dnf,pacmanandzypper. The tool-to-package mapping ispackage_for().
When a session's prerequisites change, the list to update is the check_session_NN function of that session, next to the README section it mirrors.
Every lab session's -live/ half is packed into one zip archive of its exercises, published on the lab-archives branch, which holds nothing else.
The archives are what students download, so they contain the exercises and nothing more: the NN-<name>-full/ reference material — solutions, and the session 05 flags and exploits — is never packed, and neither is anything outside content/labs/.
ARCHIVE_SECTION and ARCHIVE_VARIANT in sessions.py are what say so.
The .github/workflows/lab-archive.yml workflow rebuilds and republishes them on every push to master that touches a lab session, and can also be run by hand from the Actions tab.
An archive is named after the session with the -live suffix stripped, and unpacks into a single directory of that name:
03-memory-ops.zip
└── 03-memory-ops/
├── 01-in-memory-db/
├── bonus-in-mem-database/
└── demo-copy-file/{global-buffer,malloc,mmap}/
- An exercise is the same thing the website calls an exercise, decided by
sessions.py: any directory below a session's-live/tree that has aREADME.md. Support code vendored inside an exercise, such as thebonus-printf/utils/printf/tree, is packed with its exercise even though it gets no page. - Only files tracked by git are packed, so an object file or a compiled binary left in the working tree is never shipped by accident. The archives are the same whether they are built from a clean checkout or from the tree you have been working in.
- Files named
prompt.txt, the notes the exercises were written from, are left out; several of them describe the solution. The list is theEXCLUDED_FILEStuple ingen_zip.py. - As a last line of defence, packing aborts outright if any file from a
-full/tree, or from a directory namedsolutions, ever reaches an archive;is_reference()insessions.pyis the guard, andgen_zip.pycalls it on every file it is about to write. - Archives are byte-for-byte reproducible: entries are sorted and timestamps are fixed. Editing one exercise therefore changes that one archive, and the workflow commits nothing at all when no content has changed.
python3 scripts/gen_zip.pyThe archives are written to archives/, which is ignored by git.
Nothing has to be installed: the script only needs Python and git.
Check what a student would get with:
unzip -l archives/03-memory-ops.zipThe workflow creates the lab-archives branch itself, so the simplest way is to open the Actions tab, pick Archive Labs on Commit and run it on master.
To do the same by hand, build the archives and commit them onto a fresh orphan branch. The commands below use a temporary worktree, so your own working tree is left alone:
python3 scripts/gen_zip.py --output /tmp/lab-archives
git worktree add --detach /tmp/archives-branch
cd /tmp/archives-branch
git checkout --orphan lab-archives
git rm -rq --cached .
cp /tmp/lab-archives/*.zip .
git add -f *.zip
git commit -m "Add the initial lab archives"
git push -u origin lab-archivesThen go back and remove the temporary worktree:
cd -
git worktree remove --force /tmp/archives-branchThe -f on git add is needed because the repository ignores *.zip, which on this branch is exactly what we want to commit.
From here on the workflow keeps the branch up to date on its own.
A file on a branch is served by GitHub at a URL of this shape:
https://github.com/<owner>/<repository>/raw/lab-archives/03-memory-ops.zip