Initial commit: Schunix as it stands before the SchOS rename

An x86-64 operating system built from xv6, now largely rewritten: SMP,
a copy-on-write root filesystem (SchFS), PCI/USB/virtio drivers, a TTF
rasteriser, and a Linux-numbered syscall ABI.

No history before this point -- the tree was never under version control.
This commit exists so the rename that follows has something to undo to.
This commit is contained in:
Nexoniarz 2026-08-09 13:47:01 +02:00
commit d0f7acf9eb
275 changed files with 51417 additions and 0 deletions

433
tools/bootcheck.ps1 Normal file
View file

@ -0,0 +1,433 @@
# Headless boot check: run Schunix under QEMU, type commands at the shell, and
# assert on what actually comes back.
#
# Booting to a prompt proves nothing on its own -- a filesystem can mount and
# still fail on the first real command, and exactly that has happened here
# before. So this drives the serial console and greps the transcript for the
# answers those commands should produce.
#
# The console is stdio with a chardev logfile: QEMU writes every byte of the
# session to a file itself, so this script only ever has to *write* to stdin.
# Reading QEMU's stdout from PowerShell needs a reader that cannot block the
# writer, and two attempts at that (a background task, then a duplex named
# pipe) both deadlocked. Letting QEMU do the logging removes the problem.
#
# QEMU's serial line has no flow control: a burst of characters gets dropped,
# and anything typed before the shell is listening is discarded outright. Hence
# the pacing below -- not superstition, just the difference between a reliable
# check and a flaky one.
#
# tools\bootcheck.ps1
# tools\bootcheck.ps1 -Tty -Ps
param(
[switch]$Fb,
# Boot schunix.iso from an emulated optical drive with NO hard disk
# attached, which is the only way to prove the ISO is self-contained.
# Needs `make iso` first.
[switch]$Iso,
# Terminal behaviour: pipes, isatty, SIGPIPE, and Ctrl-C. None of these
# can be checked by looking at a screenshot, and all of them were broken
# in ways that looked like broken *commands*.
[switch]$Tty,
# Attach an xHCI controller with a keyboard, a mouse and a mass storage
# device on it. Nothing on the default machine has a USB port, so without
# this the USB driver is never asked to do anything and a broken one looks
# exactly like a working one.
[switch]$Usb,
# Unix domain sockets. Nothing in the default command set opens one, and a
# socket layer that accepted every call and dropped every byte would look
# identical from the outside -- socktest has one process write and another
# read, which is the only check that can tell the difference.
[switch]$Sock,
# Raw input events. Needs the QEMU monitor for the same reason the USB run
# does: a key has to be *pressed*, and characters typed at the serial port
# never touch the keyboard controller at all.
[switch]$Evdev,
# Process enumeration. /proc is a synthetic filesystem now, and the thing
# it exists for -- one directory per process -- is exactly what nothing
# else in this suite touches.
[switch]$Ps,
[int]$BootSeconds = 20,
[string]$Qemu = "$env:USERPROFILE\scoop\apps\qemu\current\qemu-system-x86_64.exe"
)
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$hda = Join-Path $root 'schunix.img'
# The root filesystem. SchFS is the only root there is, so this is not a
# choice -- a kernel handed anything else on device 1 panics in forkret().
$hdb = Join-Path $root 'build\schfs.img'
$log = Join-Path $root 'build\bootcheck.log'
Remove-Item -Force $log -ErrorAction SilentlyContinue
# The default run exercises the *root* filesystem: create a file, read it back,
# grow it, list a directory, seek around it. Booting to a prompt proves nothing
# on its own, and every one of these has broken at some point while the boot
# still looked perfectly healthy.
$commands = @(
'echo hello-from-schunix > /tmp/bc.txt'
'cat /tmp/bc.txt'
'echo second-line >> /tmp/bc.txt'
'wc -c /tmp/bc.txt'
'mkdir /tmp/bcdir'
'cp /tmp/bc.txt /tmp/bcdir/copy.txt'
'cat /tmp/bcdir/copy.txt'
'ls /tmp/bcdir'
'rm /tmp/bcdir/copy.txt'
'rmdir /tmp/bcdir'
'ls /bin | wc -l'
'cat /proc/pci'
)
# Every command above already went to SchFS. What is left is to show that
# it is SchFS they went to, and that the volume noticed: the generation
# counter must advance across the writes, which is the one thing a
# read-only mount or a silently-discarded write could not fake.
$commands += 'cat /proc/schfs'
# Mounting the root device a second time has to fail. It is the check that
# tells "the volume is genuinely mounted as /" apart from "it happens to be
# readable", and getting EBUSY here is the correct answer, not a defect.
$commands += 'mkdir /mnt'
$commands += 'mount -t schfs /dev/hdb /mnt'
# A deep path built and torn down entirely on the root volume.
$commands += 'mkdir /tmp/rootdir'
$commands += 'mkdir /tmp/rootdir/sub'
$commands += 'echo schfs-is-root > /tmp/rootdir/sub/deep.txt'
$commands += 'cat /tmp/rootdir/sub/deep.txt'
$commands += 'ls /tmp/rootdir/sub'
$commands += 'rm /tmp/rootdir/sub/deep.txt'
$commands += 'rmdir /tmp/rootdir/sub'
$commands += 'rmdir /tmp/rootdir'
# A binary loaded off the root volume, not a shell builtin: exec() reading
# an ELF through schfs_ops is the path that carried init itself.
$commands += 'wc -l /etc/motd'
# The second dump. The generation counter is compared against the first
# one: on a copy-on-write volume it moves on every committed transaction,
# so a root that silently discarded its writes would still pass every read
# above and fail here.
$commands += 'cat /proc/schfs'
if ($Fb) {
# /dev/fb0. The geometry has to come back from the mode the bootloader
# actually programmed -- three places in the kernel used to assume
# 0xfd000000 at 800x600x32, and nothing would have said when that stopped
# being true. `bars` goes through the device's write(), `fill` through
# mmap, so a failure names which of the two paths broke.
$commands += 'fbinfo'
$commands += 'fbinfo bars'
$commands += 'fbinfo fill'
}
if ($Tty) {
# `ls` piped into `wc -l` reported 48 rather than 240 because ioctl
# answered TIOCGWINSZ on a *pipe* with success, so ls formatted in
# columns. `yes` failed outright because writev capped iovcnt at 16 and
# toybox builds 256 of them. `cat | head` killed the shell, because
# SIG_IGN is handler value 1 and signal delivery jumped to address 1.
# Three different-looking bugs, none of them in the commands.
$commands += 'ls /bin | wc -l'
$commands += 'yes | head -2'
# 4 bytes, not 3: an ELF file starts with 0x7F 'E' 'L' 'F', so three bytes
# stops at "EL" and the invisible leading byte makes that easy to misread.
$commands += 'cat /bin/toybox | head -c 4; echo'
$commands += 'printf ''caf\303\251-\303\261-\303\266\n'''
$commands += 'echo TTY-SURVIVED'
# Shared memory and file-backed mmap: the two things a display server
# cannot be written without, and the two whose failure mode is a
# successful-looking return value.
$commands += 'shmtest'
}
if ($Usb) {
# /proc/interrupts is how you tell a registered handler from one that
# merely compiled. A line with a zero count is a device that was found and
# never heard from.
$commands += 'cat /proc/interrupts'
# Read a real block off the USB stick, through the buffer cache. Answering
# INQUIRY proves a device is there; only this proves it can read.
$commands += 'echo s > /proc/sysrq'
}
if ($Sock) {
$commands += 'socktest'
}
if ($Ps) {
$commands += 'ls /proc'
$commands += 'cat /proc/1/stat'
$commands += 'ps'
$commands += 'ps -A -o pid,ppid,comm'
$commands += 'top -b -n 1 | head -6'
}
$commands += 'echo MARKER-DONE'
# The ISO run attaches no hard disk at all. That is the whole point: a kernel
# that still quietly needs one would boot happily here otherwise, and only
# fail on someone else's machine.
$isoPath = Join-Path $root 'schunix.iso'
if ($Iso -and -not (Test-Path $isoPath)) { throw "no $isoPath -- run 'make iso' first" }
$drives = if ($Iso) {
@("-cdrom `"$isoPath`"", '-boot d')
} else {
@("-drive file=`"$hda`",format=raw,index=0,media=disk"
"-drive file=`"$hdb`",format=raw,index=1,media=disk")
}
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Qemu
$usbDevices = if ($Usb) {
# One device of each speed class QEMU can produce: usb-storage negotiates
# SuperSpeed on qemu-xhci, the HID devices come up high speed. They take
# different paths through enumeration -- a USB3 port arrives already
# enabled and a USB2 port has to be reset first -- so one of each is the
# difference between testing the driver and testing half of it.
@('-device qemu-xhci,id=xhci'
'-device usb-kbd,bus=xhci.0'
# The mouse goes behind a hub, so the tree is a tree and not a row of
# root ports. That exercises the route string, the hub's own slot
# context, and the transaction translator -- none of which a device
# plugged straight into the controller touches at all.
# QEMU places a device under a hub by port *path*, not by a child bus:
# the hub sits on port 2 and the mouse on port 2.1 of the same bus.
'-device usb-hub,id=hub,bus=xhci.0,port=2'
'-device usb-mouse,bus=xhci.0,port=2.1'
# Always the v1 image, never $hdb. The checks below read a v1 superblock
# off this stick and assert on its sector count and size field, so the
# stick has to be a v1 image whatever the root filesystem is -- under
# the root $hdb is a SchFS volume and those fields are simply not
# there, which failed three USB checks that had nothing wrong with USB.
# It also stops the same file being attached twice, once as the root
# disk and once as a USB device.
"-drive id=usbstick,file=`"$(Join-Path $root 'build\fs.img')`",format=raw,if=none"
'-device usb-storage,bus=xhci.0,drive=usbstick')
} else { @() }
# The USB run needs the QEMU monitor, because `sendkey` is the only way to
# press a key on an emulated USB keyboard -- typing on stdin goes to the
# serial port and proves nothing about USB.
# Ask the OS for a free port rather than hardcoding one. A run that dies
# before its `finally` leaves QEMU holding the old number, and every later run
# then fails with "Failed to find an available port" -- which looks like a
# bug in whatever is being tested rather than in the harness.
$monPort = if ($Usb -or $Evdev) {
$l = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
$l.Start(); $p = $l.LocalEndpoint.Port; $l.Stop(); $p
} else { 0 }
$monitor = if ($Usb -or $Evdev) { @("-monitor tcp:127.0.0.1:$monPort,server,nowait") } else { @() }
$psi.Arguments = ($drives + $usbDevices + $monitor + @(
'-m 1G -smp 1 -cpu qemu64,+rdtscp -machine pc-i440fx-9.2'
'-display none'
"-chardev stdio,id=c0,logfile=`"$log`""
'-serial chardev:c0'
)) -join ' '
$psi.RedirectStandardInput = $true
$psi.UseShellExecute = $false
$what = if ($Iso) { 'schunix.iso, no hard disk' } else { "hdb = $(Split-Path $hdb -Leaf)" }
Write-Host "==> booting ($what)" -ForegroundColor Cyan
$p = [System.Diagnostics.Process]::Start($psi)
try {
Start-Sleep -Seconds $BootSeconds # wait for the shell prompt
if ($Usb) {
# Type a whole command on the *USB* keyboard. QEMU routes sendkey to
# the most recently attached keyboard, so with usb-kbd present these
# keystrokes go through xHCI, the interrupt endpoint, the HID report
# decoder and the console -- none of which the serial path touches.
# If any link in that chain is broken the characters simply vanish,
# which is exactly how the missing interrupt showed up.
Write-Host ' > (usb keyboard) echo usbworks' -ForegroundColor DarkGray
$mon = New-Object System.Net.Sockets.TcpClient('127.0.0.1', $monPort)
$monw = New-Object System.IO.StreamWriter($mon.GetStream())
$monw.AutoFlush = $true
Start-Sleep -Milliseconds 400
foreach ($k in @('e','c','h','o','spc','u','s','b','w','o','r','k','s','ret')) {
$monw.WriteLine("sendkey $k")
Start-Sleep -Milliseconds 200
}
Start-Sleep -Seconds 2
$mon.Close()
}
# Wait until the shell has finished the last command and printed a fresh
# prompt, rather than sleeping a fixed interval and hoping.
#
# A flat 4-second wait was wrong in both directions: too long for the
# dozens of commands that answer instantly, and too short for `ls /bin |
# wc -l`, which reliably overran it. When it overran, the next command was
# typed while the shell was still busy -- the tty echoed it but no reader
# took it, and it ended up concatenated onto the following one. The
# transcript showed `cat /proc/pcicat /proc/schfs` and three PCI checks
# failed, which looks exactly like a broken PCI driver and is not one.
#
# This cannot read QEMU's stdout -- see the header; two attempts at that
# deadlocked. It reads the logfile QEMU is writing instead, which is the
# same bytes with none of the plumbing.
function Wait-Prompt([int]$TimeoutMs = 20000) {
$deadline = (Get-Date).AddMilliseconds($TimeoutMs)
while ((Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 150
$tail = ''
try { $tail = Get-Content $log -Raw -ErrorAction Stop } catch { continue }
if ($null -eq $tail) { continue }
# A prompt at the very end, with nothing typed after it.
if ($tail -match '(?s)#[ ]?$') { return $true }
}
return $false
}
foreach ($cmd in $commands) {
Write-Host " > $cmd" -ForegroundColor DarkGray
foreach ($ch in ($cmd + "`n").ToCharArray()) {
$p.StandardInput.Write($ch)
$p.StandardInput.Flush()
Start-Sleep -Milliseconds 30 # no flow control: pace the input
}
if (-not (Wait-Prompt)) {
Write-Host " (no prompt after '$cmd' -- continuing)" -ForegroundColor Yellow
Start-Sleep -Seconds 2
}
}
if ($Evdev) {
# Start a reader on /dev/input/event0 and then press keys at it. The
# program blocks in read() until events arrive, so if the keyboard
# driver is not feeding the input layer this hangs and prints nothing
# -- which is a failure that says exactly which half is broken.
Write-Host ' > inputtest -n 6, then press keys' -ForegroundColor DarkGray
foreach ($ch in "inputtest -n 6`n".ToCharArray()) {
$p.StandardInput.Write($ch); $p.StandardInput.Flush()
Start-Sleep -Milliseconds 30
}
Start-Sleep -Seconds 2
$mon = New-Object System.Net.Sockets.TcpClient('127.0.0.1', $monPort)
$monw = New-Object System.IO.StreamWriter($mon.GetStream())
$monw.AutoFlush = $true
Start-Sleep -Milliseconds 400
# 'a' is keycode 30 and shift is 42, chosen because both numbers are
# unmistakable in the output and neither is a scancode by accident.
# Each sendkey is a press *and* a release, which is the whole point:
# the console would report one character and nothing else.
foreach ($k in @('a', 'shift', 'a', 'b')) {
$monw.WriteLine("sendkey $k")
Start-Sleep -Milliseconds 400
}
$mon.Close()
Start-Sleep -Seconds 3
# Those keypresses reached the console as well as the input device --
# that is the design, not a leak -- so the shell's input line now holds
# "aab". Clear it before typing the next command, or the shell tries to
# run `aabecho`.
$p.StandardInput.Write("`n"); $p.StandardInput.Flush()
Start-Sleep -Milliseconds 500
foreach ($ch in "echo INPUT-DONE`n".ToCharArray()) {
$p.StandardInput.Write($ch); $p.StandardInput.Flush()
Start-Sleep -Milliseconds 30
}
Start-Sleep -Seconds 3
}
if ($Tty) {
# Ctrl-C has to reach the *foreground process group*. The old handler
# set killed=1 on whatever process happened to be running when the
# keyboard interrupt fired, which is almost never the one on screen.
Write-Host ' > sleep 30, then Ctrl-C' -ForegroundColor DarkGray
foreach ($ch in "sleep 30`n".ToCharArray()) {
$p.StandardInput.Write($ch); $p.StandardInput.Flush()
Start-Sleep -Milliseconds 30
}
Start-Sleep -Seconds 2
$p.StandardInput.Write([char]3) # ^C
$p.StandardInput.Flush()
Start-Sleep -Seconds 2
foreach ($ch in "echo INTERRUPTED-OK`n".ToCharArray()) {
$p.StandardInput.Write($ch); $p.StandardInput.Flush()
Start-Sleep -Milliseconds 30
}
Start-Sleep -Seconds 3
}
} finally {
if (-not $p.HasExited) { $p.Kill() }
Start-Sleep -Milliseconds 800
}
if (-not (Test-Path $log)) { throw "no serial log produced at $log" }
$text = Get-Content $log -Raw
if ($null -eq $text) { $text = '' }
Write-Host "==> serial transcript ($($text.Length) bytes) -> $log" -ForegroundColor Cyan
Write-Host $text
$script:fail = $false
function Expect($pattern, $what) {
if ($script:text -match $pattern) {
Write-Host " [ok] $what" -ForegroundColor Green
} else {
Write-Host " FAIL: $what (no match for /$pattern/)" -ForegroundColor Red
$script:fail = $true
}
}
Expect 'MARKER-DONE' 'shell stayed alive through every command'
Expect 'hello-from-schunix' 'a file written on the root filesystem reads back'
Expect 'second-line' 'appending to that file works'
# 19 bytes of "hello-from-schunix\n" plus 12 of "second-line\n". The exact
# number is the point: an append that silently truncated still produced a file,
# and only the byte count said which one.
Expect '(?m)^\s*31\s' 'append extended the file rather than replacing it'
Expect '(?m)^copy\.txt' 'a copied file appears in a new directory'
Expect '(?ms)copy\.txt.*rmdir' 'rmdir removed the directory without an error'
# PCI. The point of these is that the numbers come off the bus rather than out
# of a header: the host bridge identifies the chipset QEMU is emulating, and
# the display controller's aperture is the same address VBE reported, checked
# by two completely independent paths.
Expect 'pci: \d+ devices' 'the PCI bus enumerated'
Expect '00:00\.0 8086:1237' 'the host bridge is where a host bridge belongs'
Expect '00:01\.1 8086:7010 IDE' 'the IDE controller was identified by class'
Expect '\[vga\]' 'a driver bound itself to the display controller'
Expect 'vga: display controller' 'the display driver found its aperture'
Expect 'schfs: mounted' 'the kernel mounted SchFS as the root filesystem'
Expect 'init: ready' 'init came up on a SchFS root'
Expect 'label:\s*schunix' '/proc/schfs reports the volume label'
Expect 'root_objectid:\s*16' '/proc/schfs reports the root object id'
Expect 'dir\s+bin' 'the root directory really contains bin/'
# / is the mounted volume, so mounting that same device again must be
# refused. Without this a kernel that had quietly fallen back to some other
# root would still pass everything else here.
Expect 'Resource busy' 'mounting the root device twice is refused'
Expect 'schfs-is-root' 'a file three directories deep reads back'
Expect '(?m)^deep\.txt' 'that file is listed by its full name'
Expect '(?m)^\s*1\s+/etc/motd' 'a binary loaded off the SchFS root runs'
# Two /proc/schfs dumps, before and after the writes. Same volume, so the
# generation must have moved -- if it has not, nothing was committed.
$gens = [regex]::Matches($text, 'generation:\s*(\d+)')
if ($gens.Count -ge 2 -and
[int]$gens[$gens.Count-1].Groups[1].Value -gt [int]$gens[0].Groups[1].Value) {
Write-Host " [ok] the volume generation advanced across the writes" -ForegroundColor Green
} else {
Write-Host " FAIL: the volume generation did not advance -- writes were not committed" -ForegroundColor Red
$script:fail = $true
}
if ($Fb) {
Expect 'id\s+schunixfb' 'FBIOGET_FSCREENINFO identifies the device'
# 800x600 is what boot/stage2.S asks VBE for. Asserting the numbers rather
# than just "the ioctl returned" is the point: the old code path returned
# success with an untouched buffer, i.e. a 0x0 screen.
Expect '(?m)^xres\s+800' 'the reported width is the mode actually set'
Expect '(?m)^yres\s+600' 'the reported height is the mode actually set'
Expect '(?m)^bits_per_pixel\s+32' 'the reported depth is the mode actually set'
Expect '(?m)^smem_start\s+0x' 'the framebuffer reports a physical address'
Expect '(?m)^line_length\s+3200' 'the stride comes from VBE, not from xres*bpp'
Expect 'transp.*absent' 'the absent alpha channel is reported as length 0'
Expect 'colour bars drawn' 'writing pixels through write() reaches the screen'
Expect 'gradient drawn' 'mmap of /dev/fb0 maps the real framebuffer'
}
if ($script:fail) { exit 1 }
Write-Host 'boot check passed' -ForegroundColor Green

85
tools/crashqr.py Normal file
View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Read the QR code back off a captured crash screen.
This is the acceptance test for the crash channel, and it is deliberately
end-to-end: it does not ask the encoder what it produced, it reads the pixels
that were actually painted on the framebuffer. Everything in between -- payload
construction in panic context, the encoder, the scaling into modules, the card
and quiet zone -- is covered by whether this comes back as text.
pwsh -File tools/crashshot.ps1 # crash on purpose, screendump it
PYTHONPATH=<dir> python tools/crashqr.py
Needs opencv-python-headless and numpy; see tools/qrcheck.py for the install.
"""
import sys
try:
import cv2
import numpy as np
except ImportError:
sys.exit("crashqr: needs opencv-python-headless and numpy")
PPM = sys.argv[1] if len(sys.argv) > 1 else "build/crashscreen.ppm"
try:
data = open(PPM, "rb").read()
except OSError as e:
sys.exit(f"crashqr: {e} -- run tools/crashshot.ps1 first")
# PPM P6 header: three whitespace-separated fields after the magic. Parsed by
# scanning rather than assuming a fixed length, because QEMU's is compact but
# nothing guarantees that.
pos, fields = 2, []
while len(fields) < 3:
while data[pos:pos + 1].isspace():
pos += 1
start = pos
while not data[pos:pos + 1].isspace():
pos += 1
fields.append(int(data[start:pos]))
pos += 1
w, h = fields[0], fields[1]
img = np.frombuffer(data[pos:pos + w * h * 3], np.uint8).reshape(h, w, 3)
grey = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
# The symbol sits on the only light panel on an otherwise purple screen, so a
# brightness threshold finds it without needing to know the layout. The white
# report text passes the same threshold, which does not matter -- the detector
# locates the symbol by its finder patterns, and handing it extra screen
# costs nothing.
mask = (grey > 240).astype(np.uint8)
ys, xs = np.nonzero(mask)
if len(xs) == 0:
sys.exit("crashqr: no light region found -- was a crash screen captured?")
crop = grey[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
# OpenCV's detector is sensitive to scale in ways unrelated to the encoding,
# so try a few before concluding anything. See the note in tools/qrcheck.py.
text = ""
for scale in (1, 2, 3, 4):
up = cv2.resize(crop, None, fx=scale, fy=scale, interpolation=cv2.INTER_NEAREST)
up = cv2.copyMakeBorder(up, 40, 40, 40, 40, cv2.BORDER_CONSTANT, value=255)
text, _, _ = cv2.QRCodeDetector().detectAndDecode(up)
if text:
break
if not text:
print("crashqr: FAILED -- the symbol on the crash screen did not decode")
sys.exit(1)
print("crashqr: decoded the crash screen's QR\n")
for line in text.split("\n"):
print(" ", line)
# The payload is a contract with whatever reads these later, so check its shape
# rather than only that something came back.
lines = text.split("\n")
ok = lines[0] == "SXPSOGM1"
print()
if not ok:
print(f"crashqr: FAILED -- expected magic 'SXPSOGM1', got {lines[0]!r}")
sys.exit(1)
print(f"crashqr: passed ({len(text)} bytes, {len(lines)} fields)")

115
tools/crashshot.ps1 Normal file
View file

@ -0,0 +1,115 @@
# Boot Schunix, crash it on purpose, and capture the crash screen as an image.
#
# The crash screen is the one part of the system you cannot look at by using
# the system, so it is the one part that quietly rots. This drives it through
# /proc/sysrq and takes a picture, which turns "does the panic screen still
# render" into something answerable in thirty seconds.
#
# QEMU's monitor is on its own socket so the serial console stays free for the
# shell. screendump writes a PPM; the PNG conversion is optional and skipped if
# ImageMagick is not around.
#
# tools\crashshot.ps1 crash the ramdisk-root build
# tools\crashshot.ps1 -SchFS with a SchFS volume attached
param(
[switch]$SchFS,
[int]$BootSeconds = 20,
[string]$Out = "build\crashscreen.ppm",
[string]$Qemu = "$env:USERPROFILE\scoop\apps\qemu\current\qemu-system-x86_64.exe"
)
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$hda = Join-Path $root 'schunix.img'
$hdb = if ($SchFS) { Join-Path $root 'build\schfs.img' } else { Join-Path $root 'build\fs.img' }
$log = Join-Path $root 'build\crashshot.log'
$ppm = Join-Path $root $Out
$mon = Join-Path $root 'build\qemu-mon.sock'
Remove-Item -Force $log, $ppm -ErrorAction SilentlyContinue
# The monitor goes to a TCP port rather than a unix socket: PowerShell can open
# a TCP client without any extra machinery, and QEMU on Windows has no unix
# sockets anyway.
$monPort = 55555
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Qemu
$psi.Arguments = @(
"-drive file=`"$hda`",format=raw,index=0,media=disk"
"-drive file=`"$hdb`",format=raw,index=1,media=disk"
'-m 1G -smp 1 -cpu qemu64,+rdtscp -machine pc-i440fx-9.2'
'-display none'
"-chardev stdio,id=c0,logfile=`"$log`""
'-serial chardev:c0'
"-monitor tcp:127.0.0.1:$monPort,server,nowait"
) -join ' '
$psi.RedirectStandardInput = $true
$psi.UseShellExecute = $false
Write-Host "==> booting" -ForegroundColor Cyan
$p = [System.Diagnostics.Process]::Start($psi)
function Send-Line($text) {
foreach ($ch in ($text + "`n").ToCharArray()) {
$p.StandardInput.Write($ch)
$p.StandardInput.Flush()
Start-Sleep -Milliseconds 30 # serial has no flow control
}
}
try {
Start-Sleep -Seconds $BootSeconds
# Put something on the display first. The whole design of the crash screen
# is that it dims what was there rather than clearing it, so a blank screen
# underneath would prove nothing.
Write-Host " > filling the screen with normal output" -ForegroundColor DarkGray
Send-Line 'ls /bin'
Start-Sleep -Seconds 3
Send-Line 'cat /proc/cpuinfo'
Start-Sleep -Seconds 2
Write-Host " > echo c > /proc/sysrq" -ForegroundColor DarkGray
Send-Line 'echo c > /proc/sysrq'
Start-Sleep -Seconds 4
Write-Host "==> capturing the framebuffer" -ForegroundColor Cyan
$client = New-Object System.Net.Sockets.TcpClient('127.0.0.1', $monPort)
$stream = $client.GetStream()
$writer = New-Object System.IO.StreamWriter($stream)
$writer.AutoFlush = $true
Start-Sleep -Milliseconds 500
# QEMU on Windows wants a native path here, not the one PowerShell shows.
$writer.WriteLine("screendump $ppm")
Start-Sleep -Seconds 2
$writer.WriteLine('quit')
Start-Sleep -Milliseconds 500
$client.Close()
} finally {
if (-not $p.HasExited) { $p.Kill() }
Start-Sleep -Milliseconds 800
}
if (Test-Path $log) {
$text = Get-Content $log -Raw
Write-Host "==> serial transcript" -ForegroundColor Cyan
# Only the crash report, not the whole boot.
$idx = $text.IndexOf('Schunix stopped')
if ($idx -ge 0) { Write-Host $text.Substring($idx) } else { Write-Host $text }
}
if (Test-Path $ppm) {
$size = (Get-Item $ppm).Length
Write-Host "==> $ppm ($size bytes)" -ForegroundColor Green
$png = [System.IO.Path]::ChangeExtension($ppm, '.png')
$magick = Get-Command magick -ErrorAction SilentlyContinue
if ($magick) {
& magick $ppm $png
Write-Host "==> $png" -ForegroundColor Green
}
} else {
Write-Host "FAIL: no screendump was produced" -ForegroundColor Red
exit 1
}

116
tools/dispshot.ps1 Normal file
View file

@ -0,0 +1,116 @@
# Boot Schunix, run the display self-test, and capture the result as an image.
#
# The display class cannot be checked without a display: the assertion is "did
# the right pixels reach the screen", and the only instrument for that is a
# screendump. This drives it through `echo d > /proc/sysrq` and takes a picture.
#
# What the pattern proves: four solid quadrants in theme colours (a stride error
# would slant the bands, a bpp error would change the colours), a white frame one
# pixel inside the edge (an off-by-one in the present rectangle would clip it),
# and the fact that every pixel was drawn into RAM before a single byte reached
# the framebuffer -- which is what double buffering means.
#
# QEMU's monitor is on its own socket so the serial console stays free for the
# shell. screendump writes a PPM; the PNG conversion is optional and skipped if
# ImageMagick is not around.
#
# tools\crashshot.ps1 crash the ramdisk-root build
# tools\crashshot.ps1 -SchFS with a SchFS volume attached
param(
[switch]$SchFS,
[int]$BootSeconds = 20,
[string]$Out = "build\dispshot.ppm",
[string]$Qemu = "$env:USERPROFILE\scoop\apps\qemu\current\qemu-system-x86_64.exe"
)
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$hda = Join-Path $root 'schunix.img'
$hdb = if ($SchFS) { Join-Path $root 'build\schfs.img' } else { Join-Path $root 'build\fs.img' }
$log = Join-Path $root 'build\crashshot.log'
$ppm = Join-Path $root $Out
$mon = Join-Path $root 'build\qemu-mon.sock'
Remove-Item -Force $log, $ppm -ErrorAction SilentlyContinue
# The monitor goes to a TCP port rather than a unix socket: PowerShell can open
# a TCP client without any extra machinery, and QEMU on Windows has no unix
# sockets anyway.
$monPort = 55555
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Qemu
$psi.Arguments = @(
"-drive file=`"$hda`",format=raw,index=0,media=disk"
"-drive file=`"$hdb`",format=raw,index=1,media=disk"
'-m 1G -smp 1 -cpu qemu64,+rdtscp -machine pc-i440fx-9.2'
'-display none'
"-chardev stdio,id=c0,logfile=`"$log`""
'-serial chardev:c0'
"-monitor tcp:127.0.0.1:$monPort,server,nowait"
) -join ' '
$psi.RedirectStandardInput = $true
$psi.UseShellExecute = $false
Write-Host "==> booting" -ForegroundColor Cyan
$p = [System.Diagnostics.Process]::Start($psi)
function Send-Line($text) {
foreach ($ch in ($text + "`n").ToCharArray()) {
$p.StandardInput.Write($ch)
$p.StandardInput.Flush()
Start-Sleep -Milliseconds 30 # serial has no flow control
}
}
try {
Start-Sleep -Seconds $BootSeconds
# Show the device list first, so the transcript records what was bound.
Write-Host " > listing devices" -ForegroundColor DarkGray
Send-Line 'cat /proc/devices'
Start-Sleep -Seconds 3
Write-Host " > echo d > /proc/sysrq" -ForegroundColor DarkGray
Send-Line 'echo d > /proc/sysrq'
Start-Sleep -Seconds 4
Write-Host "==> capturing the framebuffer" -ForegroundColor Cyan
$client = New-Object System.Net.Sockets.TcpClient('127.0.0.1', $monPort)
$stream = $client.GetStream()
$writer = New-Object System.IO.StreamWriter($stream)
$writer.AutoFlush = $true
Start-Sleep -Milliseconds 500
# QEMU on Windows wants a native path here, not the one PowerShell shows.
$writer.WriteLine("screendump $ppm")
Start-Sleep -Seconds 2
$writer.WriteLine('quit')
Start-Sleep -Milliseconds 500
$client.Close()
} finally {
if (-not $p.HasExited) { $p.Kill() }
Start-Sleep -Milliseconds 800
}
if (Test-Path $log) {
$text = Get-Content $log -Raw
Write-Host "==> serial transcript" -ForegroundColor Cyan
# Only the crash report, not the whole boot.
$idx = $text.IndexOf('display: presented')
if ($idx -ge 0) { Write-Host $text.Substring($idx) } else { Write-Host $text }
}
if (Test-Path $ppm) {
$size = (Get-Item $ppm).Length
Write-Host "==> $ppm ($size bytes)" -ForegroundColor Green
$png = [System.IO.Path]::ChangeExtension($ppm, '.png')
$magick = Get-Command magick -ErrorAction SilentlyContinue
if ($magick) {
& magick $ppm $png
Write-Host "==> $png" -ForegroundColor Green
}
} else {
Write-Host "FAIL: no screendump was produced" -ForegroundColor Red
exit 1
}

87
tools/fbshot.ps1 Normal file
View file

@ -0,0 +1,87 @@
# Draw on /dev/fb0 and photograph the result.
#
# bootcheck -Fb proves fbinfo *ran* and that the numbers it read back are the
# ones the bootloader programmed. It cannot prove the pixels landed where they
# were meant to: a framebuffer written with the wrong stride still reports
# every byte accepted, and the only symptom is a picture that shears
# progressively down the screen. That is a thing you have to look at.
#
# tools\fbshot.ps1 gradient through mmap
# tools\fbshot.ps1 -Mode bars colour bars through write()
param(
[ValidateSet('fill', 'bars')]
[string]$Mode = 'fill',
[int]$BootSeconds = 20,
[string]$Qemu = "$env:USERPROFILE\scoop\apps\qemu\current\qemu-system-x86_64.exe"
)
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$hda = Join-Path $root 'schunix.img'
$hdb = Join-Path $root 'build\fs.img'
$log = Join-Path $root 'build\fbshot.log'
$ppm = Join-Path $root "build\fb-$Mode.ppm"
Remove-Item -Force $log, $ppm -ErrorAction SilentlyContinue
$monPort = 55556 # not crashshot's 55555, so the two can run back to back
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Qemu
$psi.Arguments = @(
"-drive file=`"$hda`",format=raw,index=0,media=disk"
"-drive file=`"$hdb`",format=raw,index=1,media=disk"
'-m 1G -smp 1 -cpu qemu64,+rdtscp -machine pc-i440fx-9.2'
'-display none'
"-chardev stdio,id=c0,logfile=`"$log`""
'-serial chardev:c0'
"-monitor tcp:127.0.0.1:$monPort,server,nowait"
) -join ' '
$psi.RedirectStandardInput = $true
$psi.UseShellExecute = $false
Write-Host "==> booting" -ForegroundColor Cyan
$p = [System.Diagnostics.Process]::Start($psi)
function Send-Line($text) {
foreach ($ch in ($text + "`n").ToCharArray()) {
$p.StandardInput.Write($ch)
$p.StandardInput.Flush()
Start-Sleep -Milliseconds 30 # serial has no flow control
}
}
try {
Start-Sleep -Seconds $BootSeconds
Write-Host " > fbinfo $Mode" -ForegroundColor DarkGray
Send-Line "fbinfo $Mode"
Start-Sleep -Seconds 8 # 1.9 MB a pixel at a time is not fast
Write-Host "==> capturing the framebuffer" -ForegroundColor Cyan
$client = New-Object System.Net.Sockets.TcpClient('127.0.0.1', $monPort)
$writer = New-Object System.IO.StreamWriter($client.GetStream())
$writer.AutoFlush = $true
Start-Sleep -Milliseconds 500
$writer.WriteLine("screendump $ppm")
Start-Sleep -Seconds 2
$writer.WriteLine('quit')
Start-Sleep -Milliseconds 500
$client.Close()
} finally {
if (-not $p.HasExited) { $p.Kill() }
Start-Sleep -Milliseconds 800
}
if (Test-Path $log) {
$text = Get-Content $log -Raw
$idx = $text.IndexOf('fbinfo')
if ($idx -ge 0) { Write-Host $text.Substring($idx) }
}
if (Test-Path $ppm) {
Write-Host "==> $ppm ($((Get-Item $ppm).Length) bytes)" -ForegroundColor Green
} else {
Write-Host 'FAIL: no screendump was produced' -ForegroundColor Red
exit 1
}

200
tools/loc.ps1 Normal file
View file

@ -0,0 +1,200 @@
# How much of Schunix is actually Schunix.
#
# `wc -l` over the whole tree answers a question nobody asked: it counts musl,
# toybox, mksh and a NetBSD checkout, which between them are
# two orders of magnitude larger than this project and none of them ours. This
# counts only first-party code, and separates the code from the comments so the
# number means something.
#
# tools\loc.ps1 the summary table
# tools\loc.ps1 -PerFile every file, largest first
# tools\loc.ps1 -All include third-party trees, for comparison
#
# Comment detection is deliberately simple: it handles // and /* */ for C and
# # for scripts, and does not try to understand a // inside a string literal.
# That is worth being honest about -- the comment ratio is indicative, not
# exact. The line and byte counts are exact.
param(
[switch]$PerFile,
[switch]$All,
[int]$Top = 30
)
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
# What counts as ours. Everything else in the tree is either somebody else's
# source or generated output, and mixing either in makes the total meaningless.
$areas = [ordered]@{
'kernel' = 'kernel'
'include' = 'include'
'boot' = 'boot'
'lib' = 'lib'
'cmd' = 'cmd'
'tools' = 'tools'
'docs' = 'docs'
'projects' = 'projects'
}
if ($All) {
$areas['ports (3rd party)'] = 'ports'
$areas['src-trunk (NetBSD)'] = 'src-trunk'
}
# Source only. Images, fonts-as-data and upstream tarballs are not code.
$exts = @('.c', '.h', '.S', '.s', '.ld', '.inc', '.ps1', '.sh', '.pl', '.py', '.md', '.mk')
function Measure-Area([string]$rel) {
$dir = Join-Path $root $rel
$res = [PSCustomObject]@{
Area = $rel; Files = 0; Lines = 0; Code = 0; Comment = 0; Blank = 0
Bytes = 0; NonWs = 0; Alpha = 0; Digit = 0; Files_ = @()
}
if (-not (Test-Path $dir)) { return $res }
Get-ChildItem -Path $dir -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object {
$f = $_
if ($exts -notcontains $f.Extension.ToLower()) { return }
# Makefiles have no extension; picked up separately below.
$text = [System.IO.File]::ReadAllText($f.FullName)
$lines = $text -split "`r?`n"
# A trailing newline produces one empty element; do not count it.
if ($lines.Length -gt 0 -and $lines[-1] -eq '') { $lines = $lines[0..($lines.Length-2)] }
$code = 0; $comment = 0; $blank = 0
$inBlock = $false
$isC = @('.c','.h','.s','.ld','.inc') -contains $f.Extension.ToLower()
foreach ($l in $lines) {
$t = $l.Trim()
if ($t -eq '') { $blank++; continue }
if ($inBlock) {
$comment++
if ($t -match '\*/') { $inBlock = $false }
continue
}
if ($isC) {
if ($t.StartsWith('//')) { $comment++; continue }
if ($t.StartsWith('/*')) {
$comment++
if ($t -notmatch '\*/') { $inBlock = $true }
continue
}
} elseif ($t.StartsWith('#') -and $f.Extension -ne '.md') {
$comment++; continue
}
$code++
}
$nonWs = ($text.ToCharArray() | Where-Object { -not [char]::IsWhiteSpace($_) }).Count
$alpha = ($text.ToCharArray() | Where-Object { [char]::IsLetter($_) }).Count
$digit = ($text.ToCharArray() | Where-Object { [char]::IsDigit($_) }).Count
$res.Files++
$res.Lines += $lines.Length
$res.Code += $code
$res.Comment += $comment
$res.Blank += $blank
$res.Bytes += $f.Length
$res.NonWs += $nonWs
$res.Alpha += $alpha
$res.Digit += $digit
$res.Files_ += [PSCustomObject]@{
Path = $f.FullName.Substring($root.Length + 1)
Lines = $lines.Length; Code = $code; Comment = $comment
}
}
return $res
}
$results = @()
foreach ($k in $areas.Keys) { $results += (Measure-Area $areas[$k]) }
# The top-level Makefile has no extension, so it is added by hand rather than
# widening the extension list and dragging in every stray file in the tree.
$mk = Join-Path $root 'Makefile'
if (Test-Path $mk) {
$mkLines = ([System.IO.File]::ReadAllText($mk) -split "`r?`n")
$r = $results | Where-Object { $_.Area -eq 'tools' } | Select-Object -First 1
if ($r) {
$r.Files++; $r.Lines += $mkLines.Length; $r.Bytes += (Get-Item $mk).Length
$r.Code += ($mkLines | Where-Object { $_.Trim() -ne '' -and -not $_.Trim().StartsWith('#') }).Count
$r.Comment += ($mkLines | Where-Object { $_.Trim().StartsWith('#') }).Count
$r.Blank += ($mkLines | Where-Object { $_.Trim() -eq '' }).Count
}
}
$fmt = @(
@{ N='Area'; E={$_.Area} }
@{ N='Files'; E={$_.Files}; A='right' }
@{ N='Lines'; E={'{0:N0}' -f $_.Lines}; A='right' }
@{ N='Code'; E={'{0:N0}' -f $_.Code}; A='right' }
@{ N='Comment'; E={'{0:N0}' -f $_.Comment}; A='right' }
@{ N='Blank'; E={'{0:N0}' -f $_.Blank}; A='right' }
@{ N='Cmt%'; E={ if ($_.Code + $_.Comment) { '{0:N0}%' -f (100 * $_.Comment / ($_.Code + $_.Comment)) } else { '-' } }; A='right' }
@{ N='Ln/File'; E={ if ($_.Files) { '{0:N0}' -f ($_.Lines / $_.Files) } else { '-' } }; A='right' }
@{ N='KiB'; E={'{0:N0}' -f ($_.Bytes / 1KB)}; A='right' }
)
Write-Host ''
Write-Host ' Schunix — first-party source' -ForegroundColor Cyan
Write-Host ''
Write-Host (($results | Where-Object { $_.Files -gt 0 } | Sort-Object Lines -Descending | Format-Table $fmt -AutoSize | Out-String))
# Two totals, because they answer different questions and conflating them
# flatters the number. "The OS" is what boots and runs on the machine; docs,
# host-side tools and the projects/ stubs are first-party but are not Schunix
# running on hardware. Linux 0.01's 10,239 lines were kernel, headers and boot
# -- no documentation and no build tooling -- so only the first total is a fair
# comparison.
$osAreas = @('kernel','include','boot','lib','cmd')
$own = $results | Where-Object { $_.Area -notin @('ports','src-trunk') }
$os = $results | Where-Object { $_.Area -in $osAreas }
$T = ($own | Measure-Object -Property Lines -Sum).Sum
$C = ($own | Measure-Object -Property Code -Sum).Sum
$M = ($own | Measure-Object -Property Comment -Sum).Sum
$B = ($own | Measure-Object -Property Blank -Sum).Sum
$F = ($own | Measure-Object -Property Files -Sum).Sum
$By = ($own | Measure-Object -Property Bytes -Sum).Sum
$NW = ($own | Measure-Object -Property NonWs -Sum).Sum
$AL = ($own | Measure-Object -Property Alpha -Sum).Sum
$DG = ($own | Measure-Object -Property Digit -Sum).Sum
Write-Host (' {0,-22} {1,12:N0}' -f 'Files', $F)
Write-Host (' {0,-22} {1,12:N0}' -f 'Lines total', $T)
Write-Host (' {0,-22} {1,12:N0} {2:N0}%' -f ' of which code', $C, (100 * $C / [Math]::Max($T,1)))
Write-Host (' {0,-22} {1,12:N0} {2:N0}%' -f ' of which comment', $M, (100 * $M / [Math]::Max($T,1)))
Write-Host (' {0,-22} {1,12:N0} {2:N0}%' -f ' of which blank', $B, (100 * $B / [Math]::Max($T,1)))
Write-Host (' {0,-22} {1,12:N0}' -f 'Lines per file', ($T / [Math]::Max($F,1)))
Write-Host ''
$osT = ($os | Measure-Object -Property Lines -Sum).Sum
$osC = ($os | Measure-Object -Property Code -Sum).Sum
$osF = ($os | Measure-Object -Property Files -Sum).Sum
Write-Host (' {0,-22} {1,12:N0} ({2:N0} code, {3} files)' -f 'THE OS ITSELF', $osT, $osC, $osF) -ForegroundColor Green
Write-Host (' {0,-22}' -f ' kernel+include+boot+lib+cmd') -ForegroundColor DarkGray
Write-Host ''
Write-Host (' {0,-22} {1,12:N0}' -f 'Bytes', $By)
Write-Host (' {0,-22} {1,12:N0}' -f 'Non-whitespace chars', $NW)
Write-Host (' {0,-22} {1,12:N0}' -f ' letters', $AL)
Write-Host (' {0,-22} {1,12:N0}' -f ' digits', $DG)
Write-Host ''
# For scale. Linux 0.01 (September 1991) is 10,239 lines by the same crude
# measure -- every line of every file in the tarball, comments included.
$lin = 10239
Write-Host (' For scale: Linux 0.01 was {0:N0} lines (kernel, headers, boot -- the' -f $lin) -ForegroundColor DarkGray
Write-Host (' same things counted above as THE OS ITSELF). Schunix is {0:N1}x that.' -f ($osT / $lin)) -ForegroundColor DarkGray
Write-Host ''
if ($PerFile) {
Write-Host ' Largest files' -ForegroundColor Cyan
# Out-String, not a bare Format-Table: two Format-Table calls writing to
# the same output stream interleave their grouping records and PowerShell
# rejects the second one outright.
$rows = ($own | ForEach-Object { $_.Files_ }) |
Sort-Object Lines -Descending | Select-Object -First $Top |
Format-Table @{N='Lines';E={'{0:N0}' -f $_.Lines};A='right'},
@{N='Code'; E={'{0:N0}' -f $_.Code};A='right'},
@{N='Cmt'; E={'{0:N0}' -f $_.Comment};A='right'},
Path -AutoSize | Out-String
Write-Host $rows
}

153
tools/makeiso.ps1 Normal file
View file

@ -0,0 +1,153 @@
# Build a bootable Schunix ISO, ready for VMware or a real machine.
#
# Wraps `make iso`, which builds the ramdisk-root variant and wraps it in an
# El Torito image. The ramdisk root is the point: the ISO carries a complete
# userland inside the kernel, so it boots with no hard disk attached at all.
#
# tools\makeiso.ps1 build it
# tools\makeiso.ps1 -Run build it, then boot it under QEMU to check
# tools\makeiso.ps1 -Clean force a full rebuild first
#
# Afterwards the ISO is schunix.iso in the repository root; point a VM's optical
# drive at it and boot from CD.
param(
[switch]$Run,
[switch]$Clean,
[string]$Qemu = "$env:USERPROFILE\scoop\apps\qemu\current\qemu-system-x86_64.exe"
)
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
Push-Location $root
function Fail($msg) {
Write-Host "FAIL: $msg" -ForegroundColor Red
Pop-Location
exit 1
}
# Git Bash, found by full path and never as bare `bash`.
#
# From PowerShell, `bash` resolves to C:\...\WindowsApps\bash.exe -- the *WSL*
# launcher -- long before anything on PATH. On this machine WSL2 cannot start at
# all, because it needs the Hyper-V hypervisor underneath the whole system and
# that is deliberately off. So calling `bash` here does not run a different
# shell, it fails with a wall of virtualisation advice and an exit code that
# looks like the build broke.
$BashCandidates = @(
"$env:ProgramFiles\Git\bin\bash.exe"
"${env:ProgramFiles(x86)}\Git\bin\bash.exe"
"$env:USERPROFILE\scoop\apps\git\current\bin\bash.exe"
"$env:LOCALAPPDATA\Programs\Git\bin\bash.exe"
)
$Bash = $BashCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $Bash) { Fail "Git Bash not found; looked in:`n $($BashCandidates -join "`n ")" }
function Invoke-Make([string]$cmd) {
# -c, not -lc: a login shell sources the profile for no benefit here.
# stderr folded in inside bash, because $ErrorActionPreference = 'Stop'
# turns a native program's stderr into a terminating error, and mkisofs
# writes a harmless warning there on every single run.
& $Bash -c "cd '$($root -replace '\\','/')' && $cmd 2>&1"
}
# An ISO needs one of these to assemble the filesystem. There is no xorriso
# package for Windows in scoop; cdrtools provides mkisofs, and tools/mkiso.sh
# accepts either -- their El Torito arguments are compatible.
$haveIso = (Get-Command xorriso -ErrorAction SilentlyContinue) -or
(Get-Command mkisofs -ErrorAction SilentlyContinue)
if (-not $haveIso) {
Write-Host "Need xorriso or mkisofs on PATH." -ForegroundColor Yellow
Write-Host " Windows: scoop install cdrtools"
Write-Host " Debian/Ubuntu: apt install xorriso"
Fail "no ISO builder"
}
if ($Clean) {
Write-Host "==> cleaning" -ForegroundColor Cyan
# Not `make clean`: that would drop the ports too, and those take an
# afternoon to rebuild on a host where they are not rebuilt at all.
Invoke-Make "rm -rf build/kernel build/lib build/cmd" | Out-Null
}
Write-Host "==> building (this makes the ramdisk-root kernel, which is larger)" -ForegroundColor Cyan
# Output captured whole rather than piped through a filter. Piping a native
# command into ForEach-Object leaves $LASTEXITCODE reflecting the pipeline
# rather than the command, which reported a perfectly successful `make iso` as
# a failure. Redirect stderr inside bash too: with $ErrorActionPreference set to
# Stop, PowerShell turns a native program's stderr into terminating errors, and
# mkisofs writes a harmless warning there every single run.
$out = Invoke-Make "make iso"
$code = $LASTEXITCODE
$out | Where-Object { $_ -match '^==>|mkiso:|nmeta|rror' } |
ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
if ($code -ne 0) {
$out | Select-Object -Last 20 | ForEach-Object { Write-Host $_ }
Fail "make iso returned $code"
}
$iso = Join-Path $root 'schunix.iso'
if (-not (Test-Path $iso)) { Fail "schunix.iso was not produced" }
# `make iso` leaves the tree holding objects compiled with -DRAMDISK_ROOT. A
# following plain `make` would then fail to link, on a missing ramdisk symbol,
# for reasons that look nothing like their cause. Put it back.
Write-Host "==> restoring the normal (disk-root) build" -ForegroundColor Cyan
Invoke-Make "rm -rf build/kernel build/lib build/cmd && make >/dev/null" | Out-Null
$mb = [Math]::Round((Get-Item $iso).Length / 1MB, 1)
Write-Host ""
Write-Host "==> $iso ($mb MB)" -ForegroundColor Green
Write-Host ""
Write-Host "VMware:" -ForegroundColor Cyan
Write-Host " New VM -> I will install the OS later -> Other / Other 64-bit"
Write-Host " CD/DVD -> Use ISO image file -> $iso, and tick Connect at power on"
Write-Host " Boot order: CD-ROM first (F2 at power on for the firmware setup)"
Write-Host ""
Write-Host " Set the VM to BIOS, not UEFI. Schunix's loader is 16-bit real"
Write-Host " mode and there is no EFI stub yet, so a UEFI-only VM will not boot."
Write-Host ""
Write-Host " Processors: as many as you like. SMP works as of 2026-08-08;"
Write-Host " check with 'cat /proc/cpuinfo', which lists the online ones."
Write-Host ""
Write-Host " RAM: 64 MB is plenty. The old 224 MB floor is gone."
if ($Run) {
Write-Host ""
Write-Host "==> booting the ISO under QEMU, no disk attached" -ForegroundColor Cyan
$log = Join-Path $root 'build\isoboot.log'
if (Test-Path $log) { Clear-Content $log }
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Qemu
$psi.Arguments = @(
"-cdrom `"$iso`" -boot d"
'-m 256M -smp 1 -cpu qemu64,+rdtscp -machine pc-i440fx-9.2'
'-display none'
"-chardev stdio,id=c0,logfile=`"$log`""
'-serial chardev:c0'
'-no-reboot'
) -join ' '
$psi.RedirectStandardInput = $true
$psi.UseShellExecute = $false
$p = [System.Diagnostics.Process]::Start($psi)
Start-Sleep -Seconds 25
if (-not $p.HasExited) { $p.Kill() }
Start-Sleep -Milliseconds 600
$text = if (Test-Path $log) { Get-Content $log -Raw } else { "" }
if ($text -match 'init: ready') {
Write-Host "==> the ISO boots to a shell" -ForegroundColor Green
($text -split "`n" | Select-Object -First 12) | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
} else {
Write-Host "==> the ISO did NOT reach a shell" -ForegroundColor Red
Write-Host $text
Pop-Location
exit 1
}
}
Pop-Location

1244
tools/mkfs.c Normal file

File diff suppressed because it is too large Load diff

91
tools/mkiso.sh Normal file
View file

@ -0,0 +1,91 @@
#!/bin/sh
# Build a bootable Schunix ISO.
#
# tools/mkiso.sh [output.iso]
#
# The disc boots through El Torito's "no emulation" mode, which is the only
# one of the three that fits how Schunix actually loads itself.
#
# floppy emulation caps the image at 2.88 MB; the ramdisk-root kernel
# alone is bigger than that.
# hard disk emulation presents the image through the BIOS's INT 13h, and
# Schunix's loader does not use INT 13h -- it drives
# the IDE controller's registers directly, which
# cannot reach a BIOS-emulated drive.
# no emulation the BIOS loads a chosen run of sectors at 0x7C00
# and gets out of the way. That is this.
#
# So the layout below is deliberate: -boot-load-size is 1 + STAGE2_SECTORS,
# which makes the BIOS place both boot/cdboot and the whole of stage2 in memory
# before anything of ours runs. That puts stage2 at 0x7E00 -- byte for byte the
# arrangement bootasm.S produces on a hard disk. From there the only difference
# is that stage2main.c reads the kernel back with ATAPI instead of ATA.
#
# STAGE2_SECTORS comes from the environment, set by the Makefile, because this
# same number also lives in boot/bootasm.S, boot/stage2main.c and the dd offsets
# in schunix.img's recipe. It was previously written out by hand in three
# separate spots in this file alone, and raising it for the boot logo is what
# turned that from a latent hazard into a broken image.
#
# NOT using -boot-info-table on purpose: it patches 56 bytes over offset 8 of
# the boot image, which is the middle of cdboot's code. stage2main.c finds
# where it was loaded from by reading the El Torito boot catalog instead.
set -e
BUILD=${BUILD:-build}
OUT=${1:-schunix.iso}
STAGE2_SECTORS=${STAGE2_SECTORS:-48}
STAGE2_BYTES=$((STAGE2_SECTORS * 512))
LOAD_SIZE=$((1 + STAGE2_SECTORS))
STAGING="$BUILD/iso"
CDBOOT="$BUILD/boot/cdboot"
STAGE2="$BUILD/boot/stage2"
KERNEL="$BUILD/kernel/kernel"
for f in "$CDBOOT" "$STAGE2" "$KERNEL"; do
[ -f "$f" ] || { echo "mkiso: missing $f -- run make first" >&2; exit 1; }
done
# The three pieces must be contiguous and at these exact sizes: stage2main.c's
# readseg() finds the kernel at sector (1 + STAGE2_SECTORS) of the boot image,
# so a short stage2 would silently shift the kernel out from under it.
cdboot_sz=$(wc -c < "$CDBOOT")
stage2_sz=$(wc -c < "$STAGE2")
[ "$cdboot_sz" -eq 512 ] || { echo "mkiso: cdboot is $cdboot_sz bytes, expected 512" >&2; exit 1; }
[ "$stage2_sz" -eq "$STAGE2_BYTES" ] || { echo "mkiso: stage2 is $stage2_sz bytes, expected $STAGE2_BYTES (STAGE2_SECTORS=$STAGE2_SECTORS)" >&2; exit 1; }
rm -rf "$STAGING"
mkdir -p "$STAGING/boot"
cat "$CDBOOT" "$STAGE2" "$KERNEL" > "$STAGING/boot/schunix.bin"
# xorriso if it is here, mkisofs otherwise. Both accept the El Torito flags
# below unchanged -- xorriso's -as mkisofs mode exists precisely to be
# argument-compatible with it. The fallback is not academic: there is no xorriso
# package for Windows in scoop, where this is developed, but cdrtools provides
# mkisofs.exe.
if command -v xorriso >/dev/null 2>&1; then
MKISOFS="xorriso -as mkisofs"
elif command -v mkisofs >/dev/null 2>&1; then
MKISOFS="mkisofs"
else
echo "mkiso: need xorriso or mkisofs on PATH" >&2
echo " Windows: scoop install cdrtools" >&2
echo " Debian/Ubuntu: apt install xorriso" >&2
exit 1
fi
$MKISOFS \
-quiet \
-V SCHUNIX \
-A "Schunix" \
-b boot/schunix.bin \
-no-emul-boot \
-boot-load-size "$LOAD_SIZE" \
-o "$OUT" \
"$STAGING"
echo "==> $OUT ($(wc -c < "$OUT") bytes)"
echo " qemu-system-x86_64 -cdrom $OUT -boot d"

184
tools/mklogo.py Normal file
View file

@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Turn Assets/*.png into a C header the kernel can draw.
Why this exists rather than a PNG decoder in the kernel: the boot logo is one
fixed image that changes about never, so decoding it at runtime would mean
carrying an inflate implementation in the kernel forever to answer a question
that is already known at build time. Precedent is include/font.h, which is the
same trade for the same reason.
Output is an 8-bit coverage mask, not colour. The logo is a single-colour
silhouette (a monogram in a ring), so what matters is *how much* of each pixel
is ink -- which lets the kernel composite it in whatever the theme's accent
happens to be, anti-aliased, at one byte per pixel. Storing RGB would be four
times the size and would hardcode a colour into the kernel image.
python3 tools/mklogo.py "Assets/Schunix Small Logo.png" include/logo.h 128
"""
import sys, zlib, struct
def read_png(path):
d = open(path, 'rb').read()
if d[:8] != b'\x89PNG\r\n\x1a\n':
raise SystemExit(f'{path}: not a PNG')
pos, idat, w, h, bd, ct = 8, b'', 0, 0, 0, 0
while pos < len(d):
ln, typ = struct.unpack('>I4s', d[pos:pos + 8])
body = d[pos + 8:pos + 8 + ln]
if typ == b'IHDR':
w, h, bd, ct = struct.unpack('>IIBB', body[:10])
elif typ == b'IDAT':
idat += body
elif typ == b'IEND':
break
pos += 12 + ln
if bd != 8 or ct not in (2, 6):
raise SystemExit(f'{path}: need 8-bit RGB or RGBA, got depth={bd} type={ct}')
nch = 4 if ct == 6 else 3
raw = zlib.decompress(idat)
# Undo PNG's per-scanline filters. Each row is prefixed with a filter byte
# and predicted from the row above and the pixel to the left; without this
# step the data is not an image at all.
stride = w * nch
out = bytearray(h * stride)
prev = bytearray(stride)
p = 0
for y in range(h):
f = raw[p]; p += 1
cur = bytearray(raw[p:p + stride]); p += stride
if f == 1:
for i in range(nch, stride):
cur[i] = (cur[i] + cur[i - nch]) & 0xFF
elif f == 2:
for i in range(stride):
cur[i] = (cur[i] + prev[i]) & 0xFF
elif f == 3:
for i in range(stride):
a = cur[i - nch] if i >= nch else 0
cur[i] = (cur[i] + ((a + prev[i]) >> 1)) & 0xFF
elif f == 4:
for i in range(stride):
a = cur[i - nch] if i >= nch else 0
b = prev[i]
c = prev[i - nch] if i >= nch else 0
pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c)
pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
cur[i] = (cur[i] + pr) & 0xFF
elif f != 0:
raise SystemExit(f'unknown PNG filter {f}')
out[y * stride:(y + 1) * stride] = cur
prev = cur
return w, h, nch, out
def ink(px, nch, x, y, w):
"""How much ink is at (x, y), 0..255.
The source is a dark mark on white with an alpha channel. Ink is therefore
'opaque and not white': darkness weighted by alpha. Using luminance alone
would make the transparent margin count as black.
"""
i = (y * w + x) * nch
r, g, b = px[i], px[i + 1], px[i + 2]
a = px[i + 3] if nch == 4 else 255
lum = (r * 299 + g * 587 + b * 114) // 1000
return ((255 - lum) * a) // 255
def main():
if len(sys.argv) < 3:
raise SystemExit(__doc__)
src, dst = sys.argv[1], sys.argv[2]
size = int(sys.argv[3]) if len(sys.argv) > 3 else 128
w, h, nch, px = read_png(src)
# Crop to the mark. These files are 1920x1080 with the logo floating in a
# large empty field; scaling that whole canvas down would waste most of the
# output on margin and leave the mark tiny.
x0, y0, x1, y1 = w, h, -1, -1
for y in range(h):
for x in range(w):
if ink(px, nch, x, y, w) > 24:
if x < x0: x0 = x
if y < y0: y0 = y
if x > x1: x1 = x
if y > y1: y1 = y
if x1 < 0:
raise SystemExit(f'{src}: no non-white pixels found')
# Square the crop so the aspect ratio survives the scale.
cw, ch = x1 - x0 + 1, y1 - y0 + 1
side = max(cw, ch)
x0 -= (side - cw) // 2
y0 -= (side - ch) // 2
# Box filter. Averaging every source pixel that lands in an output cell is
# what keeps the ring's edge smooth; nearest-neighbour at this reduction
# ratio (roughly 8:1) would break it into dashes.
mask = bytearray(size * size)
for oy in range(size):
sy0 = y0 + oy * side // size
sy1 = max(sy0 + 1, y0 + (oy + 1) * side // size)
for ox in range(size):
sx0 = x0 + ox * side // size
sx1 = max(sx0 + 1, x0 + (ox + 1) * side // size)
tot = n = 0
for sy in range(sy0, sy1):
if sy < 0 or sy >= h: continue
for sx in range(sx0, sx1):
if sx < 0 or sx >= w: continue
tot += ink(px, nch, sx, sy, w); n += 1
mask[oy * size + ox] = tot // n if n else 0
# Normalise to full strength. The source art is a mid-grey gradient on
# white, so its darkest pixel is only about 60% "ink" -- composited as-is
# against a near-black background the mark comes out muddy. Scaling by the
# peak keeps the gradient's internal shape (the relative values are
# unchanged) while letting the brightest part of the logo actually reach the
# accent colour.
peak = max(mask)
if peak:
for i, v in enumerate(mask):
mask[i] = min(255, v * 255 // peak)
# Pack two pixels per byte. This header is included by the *bootloader* as
# well as the kernel, and stage2 has to fit between 0x7E00 and the 0x10000
# kernel-search buffer -- so the mask's size is a boot-time constraint, not
# just a matter of taste. 16 grey levels is visually indistinguishable from
# 256 for a single-colour mark, and halves the cost.
packed = bytearray(len(mask) // 2)
for i in range(0, len(mask), 2):
packed[i // 2] = ((mask[i] // 17) << 4) | (mask[i + 1] // 17)
with open(dst, 'w', newline='\n') as f:
f.write('#pragma once\n\n')
f.write('// GENERATED by tools/mklogo.py -- do not edit. Run `make logo`.\n')
f.write(f'// source: {src}\n')
f.write('//\n')
f.write('// An ink-coverage mask, not colour: 0 is background and full scale is\n')
f.write('// solid logo. Whoever draws it picks the colour, so the palette stays in\n')
f.write('// include/theme.h and can change without regenerating anything.\n')
f.write('//\n')
f.write('// Two pixels per byte, high nibble first. Included by boot/stage2main.c\n')
f.write('// as well as kernel/dev/vga.c, and stage2 must fit below the 0x10000\n')
f.write('// kernel-search buffer -- hence 4 bits rather than 8.\n\n')
f.write(f'#define LOGO_W {size}\n#define LOGO_H {size}\n\n')
f.write('// Ink at linear pixel index i, 0..255.\n')
f.write('#define LOGO_INK(i) ((unsigned char)((((i) & 1) '
'? (logo_mask[(i) >> 1] & 0xF) \\\n')
f.write(' : (logo_mask[(i) >> 1] >> 4)) * 17))\n\n')
f.write('static const unsigned char logo_mask[LOGO_W * LOGO_H / 2] = {\n')
for i in range(0, len(packed), 16):
f.write(' ' + ','.join('0x%02x' % b for b in packed[i:i + 16]) + ',\n')
f.write('};\n')
nz = sum(1 for b in mask if b)
print(f'{dst}: {size}x{size}, cropped from {cw}x{ch} at ({x0},{y0}), '
f'{nz} inked pixels, {len(packed)} bytes packed (4bpp)')
main()

650
tools/mkschfs.c Normal file
View file

@ -0,0 +1,650 @@
// SPDX-License-Identifier: Apache-2.0
//
// mkschfs — build a SchFS image from a host directory tree.
//
// This is the tool that makes the on-disk format real: everything the kernel
// mounts in phase 2 is produced here. It deliberately shares the actual
// filesystem code (btree.c, vol.c, obj.c, objw.c) rather than reimplementing
// the layout, so a disagreement between writer and reader is impossible by
// construction — there is only one implementation, exercised on the host where
// a mistake costs a second instead of a boot.
//
// mkschfs out.img --sysroot DIR [--size BLOCKS] [--label NAME]
//
// Two behaviours worth knowing about:
//
// * Identical file contents are stored once and hard-linked. sysroot/bin
// holds a full copy of the toybox binary per applet, so this is the
// difference between a 190 MB image and a 6 MB one.
// * Output is reproducible. Directory entries are sorted, the UUID is
// derived rather than random, and timestamps come from the source files,
// so building the same tree twice gives byte-identical images and a diff
// means something actually changed.
#define _FILE_OFFSET_BITS 64
#define SCHFS_HOSTED 1
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <dirent.h>
#include <errno.h>
#include <sys/stat.h>
#include <sys/types.h>
/* major()/minor() for device nodes. glibc puts them here; mingw has no such
header and no device numbers at all, because Windows has no mknod. On that
host the macros are stubbed: a device node cannot exist in the source tree
being imaged, so the values are never anything but zero, and stubbing is
honest where pretending to read st_rdev would not be. */
#ifdef __MINGW32__
#define major(x) 0
#define minor(x) 0
/* Symlinks, likewise. Windows has no lstat/readlink and mingw exposes neither,
so on this host a symlink cannot be encountered while walking the tree --
lstat degrades to stat, nothing is ever S_ISLNK, and readlink can only be
reached through a branch that is now unreachable. Verified rather than
assumed: `find sysroot -type l` returns nothing.
If symlinks are ever staged (procfs wants /proc/<pid>/exe to be one), this
host stops being able to build a faithful image and the stub must become a
real Win32 reparse-point read. Failing loudly then is the point of the
abort() below. */
#define lstat(p, b) stat((p), (b))
#define S_ISLNK(m) 0
static long readlink(const char *p, char *b, unsigned long n)
{ (void)p; (void)b; (void)n; return -1; }
#else
#include <sys/sysmacros.h>
#endif
#include "../kernel/fs/schfs/btree.c"
#include "../kernel/fs/schfs/space.c"
#include "../kernel/fs/schfs/vol.c"
#include "../kernel/fs/schfs/obj.c"
#include "../kernel/fs/schfs/objw.c"
/* ------------------------------------------------------------------ */
/* File-backed block device */
/* ------------------------------------------------------------------ */
struct imgdev {
FILE *f;
struct schfs_vol *vol;
uint64_t nblocks;
long reads, writes;
};
static struct imgdev IMG;
static int
img_read(void *ctx, uint64_t b, void *buf)
{
struct imgdev *d = ctx;
if (b >= d->nblocks)
return -1;
if (fseeko(d->f, (off_t)b * SCHFS_BLKSIZE, SEEK_SET) != 0)
return -1;
if (fread(buf, 1, SCHFS_BLKSIZE, d->f) != SCHFS_BLKSIZE)
return -1;
d->reads++;
return 0;
}
static int
img_write(void *ctx, uint64_t b, const void *buf)
{
struct imgdev *d = ctx;
if (b >= d->nblocks)
return -1;
if (fseeko(d->f, (off_t)b * SCHFS_BLKSIZE, SEEK_SET) != 0)
return -1;
if (fwrite(buf, 1, SCHFS_BLKSIZE, d->f) != SCHFS_BLKSIZE)
return -1;
d->writes++;
return 0;
}
/* ------------------------------------------------------------------ */
/* Allocation */
/* */
/* Straight through to the volume's space manager. An earlier version */
/* kept a build-time free list of its own, because the bump allocator it */
/* replaced could only ever take back the block it handed out last and */
/* a build otherwise leaked one root-to-leaf path per insert. Two things */
/* made that unnecessary: the space manager reuses any freed block, and */
/* the tree now rewrites its own nodes in place within a transaction */
/* group, so a build barely frees anything in the first place. */
/* ------------------------------------------------------------------ */
static uint64_t
img_alloc(void *ctx)
{
return schfs_vol_alloc(((struct imgdev *)ctx)->vol);
}
static uint64_t
img_alloc_run(void *ctx, uint64_t n)
{
return schfs_vol_alloc_run(((struct imgdev *)ctx)->vol, n);
}
static void
img_free(void *ctx, uint64_t b)
{
schfs_vol_free(((struct imgdev *)ctx)->vol, b);
}
// Pages for the allocation bitmap. calloc rather than malloc so a bug that
// forgets to initialise one shows up as "everything is free" consistently
// rather than as whatever the heap happened to contain.
static void *
img_palloc(void)
{
return calloc(1, SCHFS_BLKSIZE);
}
/* ------------------------------------------------------------------ */
/* Content dedup */
/* */
/* sysroot/bin contains one independent ~800 KB copy of toybox per */
/* applet name. Storing each separately is the difference between an */
/* image that fits in RAM and one that does not, so identical content */
/* becomes one object with several names -- a plain hard link, which */
/* the format already supports through nlink. */
/* ------------------------------------------------------------------ */
struct dedup_ent {
char *path;
dev_t hdev; /* host device+inode: when these match, */
ino_t hino; /* it is literally the same file */
off_t size;
uint32_t crc;
uint64_t ino;
};
static struct dedup_ent *dedup_tab;
static int dedup_n, dedup_cap;
/* A file the host already told us is the same file.
*
* ports/toybox/Makefile hard-links its 236 applet names to one binary, so this
* settles all but the first of them without reading anything: the content path
* below reads the candidate to CRC it and then re-reads both files to confirm,
* which is half a gigabyte of I/O for an answer fstat already has. */
static uint64_t
dedup_lookup_ino(dev_t hdev, ino_t hino)
{
if (hino == 0)
return 0;
for (int i = 0; i < dedup_n; i++)
if (dedup_tab[i].hdev == hdev && dedup_tab[i].hino == hino)
return dedup_tab[i].ino;
return 0;
}
static int
files_identical(const char *a, const char *b)
{
FILE *fa = fopen(a, "rb"), *fb = fopen(b, "rb");
if (!fa || !fb) { if (fa) fclose(fa); if (fb) fclose(fb); return 0; }
int same = 1;
for (;;) {
char ba[8192], bb[8192];
size_t na = fread(ba, 1, sizeof(ba), fa);
size_t nb = fread(bb, 1, sizeof(bb), fb);
if (na != nb || memcmp(ba, bb, na) != 0) { same = 0; break; }
if (na == 0) break;
}
fclose(fa); fclose(fb);
return same;
}
// Size and CRC agreeing is not proof, so a candidate is confirmed byte by byte.
// Silently hard-linking two files that merely collide would corrupt the image
// in a way nothing downstream could detect.
static uint64_t
dedup_lookup(const char *path, off_t size, uint32_t crc)
{
for (int i = 0; i < dedup_n; i++)
if (dedup_tab[i].size == size && dedup_tab[i].crc == crc &&
files_identical(dedup_tab[i].path, path))
return dedup_tab[i].ino;
return 0;
}
static void
dedup_remember(const char *path, const struct stat *st,
off_t size, uint32_t crc, uint64_t ino)
{
dev_t dedup_hdev = st->st_nlink > 1 ? st->st_dev : 0;
ino_t dedup_hino = st->st_nlink > 1 ? st->st_ino : 0;
if (dedup_n == dedup_cap) {
dedup_cap = dedup_cap ? dedup_cap * 2 : 256;
dedup_tab = realloc(dedup_tab, (size_t)dedup_cap * sizeof(*dedup_tab));
if (!dedup_tab) { perror("realloc"); exit(1); }
}
dedup_tab[dedup_n].path = strdup(path);
dedup_tab[dedup_n].hdev = dedup_hdev;
dedup_tab[dedup_n].hino = dedup_hino;
dedup_tab[dedup_n].size = size;
dedup_tab[dedup_n].crc = crc;
dedup_tab[dedup_n].ino = ino;
dedup_n++;
}
/* ------------------------------------------------------------------ */
static struct schfs_vol VOL;
static char DATABLK[SCHFS_BLKSIZE];
static char CURBLK[SCHFS_BLKSIZE];
// The volume's three scratch blocks. Statics rather than locals because the
// same code runs in the kernel, where a 4096-byte local is the whole stack —
// keeping the shapes identical is what makes the host the place bugs are found.
static char CTX_A[SCHFS_BLKSIZE];
static char CTX_B[SCHFS_BLKSIZE];
static char CTX_C[SCHFS_BLKSIZE];
static struct schfs_ctx CTX = { CTX_A, CTX_B, CTX_C };
static long nfiles, ndirs, nlinks, nsymlinks, ndevs;
static long long nbytes, ndeduped;
static void
die(const char *what, int r)
{
fprintf(stderr, "mkschfs: %s failed (%d)%s\n", what, r,
r == -3 ? " -- image is full, try a larger --size" : "");
exit(1);
}
static void
inode_init(struct schfs_inode *in, const struct stat *st, uint16_t type,
uint64_t parent)
{
memset(in, 0, sizeof(*in));
in->type = type;
in->parent = parent;
in->nlink = 1;
in->mode = st ? (uint32_t)(st->st_mode & 07777)
: (type == SCHFS_T_DIR ? 0755u : 0644u);
in->uid = 0;
in->gid = 0;
if (st) {
// All three come from mtime, deliberately.
//
// Copying the host's atime would make the output depend on when the
// source files were last *read* -- and mkschfs reads every one of them,
// so building the same tree twice produced two different images. ctime
// is similar: it tracks host inode changes that mean nothing inside the
// image. mtime is the only one that describes the content, which is the
// only thing being copied.
in->atime = (uint64_t)st->st_mtime;
in->mtime = (uint64_t)st->st_mtime;
in->ctime = (uint64_t)st->st_mtime;
}
}
static void
bump_nlink(uint64_t ino)
{
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
struct schfs_inode in;
int r = schfs_inode_get(&VOL, &c, ino, &in);
if (r != 0)
die("re-reading an inode for a hard link", r);
in.nlink++;
// Under v1 this was the bug that shipped broken images: nlink was bumped
// but the inode's checksum was not recomputed, so every deduped file failed
// its own integrity check on first open. Here the checksum belongs to the
// tree node and is recomputed by the insert, so it cannot get out of step.
r = schfs_inode_put(&VOL, ino, &in);
if (r != 0)
die("writing back a hard link count", r);
}
static uint64_t
add_file(uint64_t parent, const char *host, const char *name,
const struct stat *st)
{
// A hard link is the same inode by definition, so ask the host before
// reading anything.
if (st->st_nlink > 1) {
uint64_t hit = dedup_lookup_ino(st->st_dev, st->st_ino);
if (hit) {
int r = schfs_dir_add(&VOL, parent, name, (unsigned)strlen(name),
hit, SCHFS_T_FILE);
if (r != 0)
die("linking a deduplicated file", r);
bump_nlink(hit);
nlinks++;
ndeduped += st->st_size;
return hit;
}
}
// CRC the content: it is both the dedup key and a cheap way to avoid
// reading candidates that cannot possibly match.
FILE *f = fopen(host, "rb");
if (!f) { perror(host); exit(1); }
uint32_t crc = 0;
for (;;) {
char buf[8192];
size_t n = fread(buf, 1, sizeof(buf), f);
if (n == 0) break;
crc = schfs_crc32c(crc, buf, (unsigned)n);
}
uint64_t existing = dedup_lookup(host, st->st_size, crc);
if (existing) {
fclose(f);
int r = schfs_dir_add(&VOL, parent, name, (unsigned)strlen(name),
existing, SCHFS_T_FILE);
if (r != 0)
die("linking a deduplicated file", r);
bump_nlink(existing);
nlinks++;
ndeduped += st->st_size;
return existing;
}
// The final size is known before a byte is written, so the block count goes
// into the inode on its first and only write, and the whole run of data
// blocks is claimed up front. That is what collapses a multi-megabyte file
// into a single extent record instead of one per 4 KB block.
uint64_t need = ((uint64_t)st->st_size + SCHFS_BLKSIZE - 1) / SCHFS_BLKSIZE;
uint64_t ino = schfs_alloc_objectid(&VOL);
struct schfs_inode in;
inode_init(&in, st, SCHFS_T_FILE, parent);
in.blocks = need;
int r = schfs_inode_put(&VOL, ino, &in);
if (r != 0) die("writing a file inode", r);
r = schfs_file_reserve(&VOL, ino, need);
if (r != 0) die("reserving data blocks", r);
rewind(f);
for (;;) {
// Large chunks on purpose: each append call re-reads and rewrites the
// inode, and every tree write copies its path, so appending a big file
// 8 KB at a time spends more space on metadata churn than on data.
static char buf[256 * 1024];
size_t n = fread(buf, 1, sizeof(buf), f);
if (n == 0) break;
r = schfs_file_append(&VOL, ino, buf, (unsigned)n, DATABLK);
if (r != 0) die("writing file data", r);
nbytes += (long long)n;
}
fclose(f);
r = schfs_dir_add(&VOL, parent, name, (unsigned)strlen(name),
ino, SCHFS_T_FILE);
if (r != 0) die("adding a directory entry", r);
dedup_remember(host, st, st->st_size, crc, ino);
nfiles++;
return ino;
}
static void
add_symlink(uint64_t parent, const char *host, const char *name,
const struct stat *st)
{
char target[4096];
ssize_t n = readlink(host, target, sizeof(target) - 1);
if (n < 0) { perror(host); exit(1); }
target[n] = '\0';
uint64_t need = ((uint64_t)n + SCHFS_BLKSIZE - 1) / SCHFS_BLKSIZE;
uint64_t ino = schfs_alloc_objectid(&VOL);
struct schfs_inode in;
inode_init(&in, st, SCHFS_T_SYMLINK, parent);
in.blocks = need;
int r = schfs_inode_put(&VOL, ino, &in);
if (r != 0) die("writing a symlink inode", r);
// The target is stored as the object's data, exactly like a file's
// contents, so nothing special is needed to read it back.
r = schfs_file_reserve(&VOL, ino, need);
if (r != 0) die("reserving a symlink block", r);
r = schfs_file_append(&VOL, ino, target, (unsigned)n, DATABLK);
if (r != 0) die("writing a symlink target", r);
r = schfs_dir_add(&VOL, parent, name, (unsigned)strlen(name),
ino, SCHFS_T_SYMLINK);
if (r != 0) die("adding a symlink entry", r);
nsymlinks++;
}
static void
add_dev(uint64_t parent, const char *name, const struct stat *st)
{
uint64_t ino = schfs_alloc_objectid(&VOL);
struct schfs_inode in;
inode_init(&in, st, SCHFS_T_DEV, parent);
in.rdev_major = (uint16_t)major(st->st_rdev);
in.rdev_minor = (uint16_t)minor(st->st_rdev);
int r = schfs_inode_put(&VOL, ino, &in);
if (r != 0) die("writing a device inode", r);
r = schfs_dir_add(&VOL, parent, name, (unsigned)strlen(name),
ino, SCHFS_T_DEV);
if (r != 0) die("adding a device entry", r);
ndevs++;
}
/* Sorted directory listing, so images are reproducible. */
struct namelist { char **v; int n, cap; };
static void
nl_add(struct namelist *l, const char *s)
{
if (l->n == l->cap) {
l->cap = l->cap ? l->cap * 2 : 64;
l->v = realloc(l->v, (size_t)l->cap * sizeof(*l->v));
if (!l->v) { perror("realloc"); exit(1); }
}
l->v[l->n++] = strdup(s);
}
static int
nl_cmp(const void *a, const void *b)
{
return strcmp(*(char *const *)a, *(char *const *)b);
}
static void add_tree(uint64_t dirino, const char *hostdir);
// Returns 1 if the entry was a directory, so the caller can total up the link
// count without walking the directory a second time.
static int
add_entry(uint64_t dirino, const char *hostdir, const char *name)
{
char path[8192];
snprintf(path, sizeof(path), "%s/%s", hostdir, name);
struct stat st;
// lstat, not stat: a symlink must be stored as a symlink rather than
// silently turned into a second copy of whatever it points at.
if (lstat(path, &st) != 0) { perror(path); exit(1); }
if (strlen(name) > SCHFS_NAME_MAX) {
fprintf(stderr, "mkschfs: name too long (max %d): %s\n",
SCHFS_NAME_MAX, path);
exit(1);
}
if (S_ISDIR(st.st_mode)) {
uint64_t ino = schfs_alloc_objectid(&VOL);
struct schfs_inode in;
inode_init(&in, &st, SCHFS_T_DIR, dirino);
in.nlink = 2; /* itself and its own "." */
int r = schfs_inode_put(&VOL, ino, &in);
if (r != 0) die("writing a directory inode", r);
r = schfs_dir_add(&VOL, dirino, name, (unsigned)strlen(name),
ino, SCHFS_T_DIR);
if (r != 0) die("adding a subdirectory entry", r);
ndirs++;
add_tree(ino, path);
return 1;
}
if (S_ISLNK(st.st_mode))
add_symlink(dirino, path, name, &st);
else if (S_ISCHR(st.st_mode) || S_ISBLK(st.st_mode))
add_dev(dirino, name, &st);
else if (S_ISREG(st.st_mode))
add_file(dirino, path, name, &st);
else
fprintf(stderr, "mkschfs: skipping %s (unsupported file type)\n", path);
return 0;
}
static void
add_tree(uint64_t dirino, const char *hostdir)
{
DIR *d = opendir(hostdir);
if (!d) { perror(hostdir); exit(1); }
struct namelist l = { 0, 0, 0 };
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0)
continue;
nl_add(&l, e->d_name);
}
closedir(d);
qsort(l.v, l.n, sizeof(*l.v), nl_cmp);
long subdirs = 0;
for (int i = 0; i < l.n; i++) {
subdirs += add_entry(dirino, hostdir, l.v[i]);
free(l.v[i]);
}
free(l.v);
// A directory's link count is 2 (itself, and its own ".") plus one per
// subdirectory, because each subdirectory's ".." points back here. ".." is
// synthetic in SchFS, but the count still has to match what POSIX tools
// expect, or "find -type d" prunes real directories and du walks the tree
// wrongly. Written last because the count is not known until the children
// have been added.
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
struct schfs_inode in;
if (schfs_inode_get(&VOL, &c, dirino, &in) != 0)
die("re-reading a directory inode", -2);
in.nlink = (uint32_t)(2 + subdirs);
if (schfs_inode_put(&VOL, dirino, &in) != 0)
die("writing a directory link count", -2);
}
/* ------------------------------------------------------------------ */
int
main(int argc, char **argv)
{
const char *out = NULL, *sysroot = NULL, *label = "schunix";
uint64_t nblocks = 6000;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--sysroot") == 0 && i + 1 < argc)
sysroot = argv[++i];
else if (strcmp(argv[i], "--size") == 0 && i + 1 < argc)
nblocks = strtoull(argv[++i], NULL, 0);
else if (strcmp(argv[i], "--label") == 0 && i + 1 < argc)
label = argv[++i];
else if (strcmp(argv[i], "--hash-bits") == 0 && i + 1 < argc) {
// Testing only: shrink the name hash so collisions are the norm
// rather than a once-in-a-lifetime event. schfs_fsck takes the
// same flag and must be given the same value to read the image.
int bits = atoi(argv[++i]);
schfs_hash_mask = bits >= 64 ? ~0ULL : ((1ULL << bits) - 1);
}
else if (argv[i][0] == '-') {
fprintf(stderr, "mkschfs: unknown option %s\n", argv[i]);
return 1;
} else if (!out)
out = argv[i];
else {
fprintf(stderr, "mkschfs: too many arguments\n");
return 1;
}
}
if (!out || !sysroot) {
fprintf(stderr,
"usage: mkschfs out.img --sysroot DIR [--size BLOCKS] [--label NAME]\n"
" BLOCKS are %d-byte SchFS blocks (default 6000 = %d MB)\n",
SCHFS_BLKSIZE, (int)(6000 * SCHFS_BLKSIZE / (1024 * 1024)));
return 1;
}
if (nblocks <= SCHFS_FIRST_BLOCK) {
fprintf(stderr, "mkschfs: --size must exceed %d blocks (boot block "
"plus the uberblock ring)\n", SCHFS_FIRST_BLOCK);
return 1;
}
FILE *f = fopen(out, "w+b");
if (!f) { perror(out); return 1; }
// Pre-extend so every block is readable. The bump allocator hands out
// blocks in order, but the tree reads a block back the moment it is
// written, and a short file would fail that read rather than the write.
if (ftruncate(fileno(f), (off_t)nblocks * SCHFS_BLKSIZE) != 0) {
perror("ftruncate");
return 1;
}
IMG.f = f;
IMG.vol = &VOL;
IMG.nblocks = nblocks;
struct schfs_bdev bd;
bd.ctx = &IMG;
bd.bread = img_read;
bd.bwrite = img_write;
bd.balloc = img_alloc;
bd.bfree = img_free;
bd.balloc_run = img_alloc_run;
int r = schfs_vol_format(&VOL, &bd, nblocks, label, &CTX, img_palloc);
if (r != 0) die("formatting", r);
// The root directory is an object like any other; nothing points at it
// except the uberblock's root_objectid.
struct schfs_inode root;
struct stat rst;
int have_rst = (lstat(sysroot, &rst) == 0);
inode_init(&root, have_rst ? &rst : NULL, SCHFS_T_DIR, 0);
root.mode = 0755;
root.nlink = 2;
r = schfs_inode_put(&VOL, VOL.root_objectid, &root);
if (r != 0) die("writing the root inode", r);
ndirs++;
add_tree(VOL.root_objectid, sysroot);
// Publish. Everything above this line is invisible to a reader; if the tool
// died half way through, the image would simply have no valid uberblock and
// be rejected as "not a SchFS volume" rather than mounted half-built.
r = schfs_vol_commit(&VOL);
if (r != 0) die("committing the uberblock", r);
if (fflush(f) != 0 || fclose(f) != 0) { perror(out); return 1; }
uint64_t used = nblocks - VOL.free_blocks - SCHFS_FIRST_BLOCK;
printf("mkschfs: %s\n", out);
printf(" %ld dirs, %ld files, %ld hard links, %ld symlinks, %ld devices\n",
ndirs, nfiles, nlinks, nsymlinks, ndevs);
printf(" %lld bytes of data written, %lld bytes saved by dedup\n",
nbytes, ndeduped);
printf(" %llu of %llu blocks used (%llu KB of %llu KB), generation %llu\n",
(unsigned long long)used, (unsigned long long)nblocks,
(unsigned long long)used * SCHFS_BLKSIZE / 1024,
(unsigned long long)nblocks * SCHFS_BLKSIZE / 1024,
(unsigned long long)VOL.generation);
return 0;
}

38
tools/mktree.sh Normal file
View file

@ -0,0 +1,38 @@
#!/bin/sh
# Build the synthetic test tree used by schfs_roundtrip.sh into $1.
# Kept separate so the tree can be recreated for debugging without running
# the whole round-trip.
set -e
TREE=${1:?usage: mktree.sh <dir>}
rm -rf "$TREE"
mkdir -p "$TREE/bin" "$TREE/etc/conf.d" "$TREE/empty-dir" "$TREE/a/b/c/d/e/f/g"
: > "$TREE/etc/empty-file"
echo "hello schfs" > "$TREE/etc/motd"
printf 'x' > "$TREE/etc/one-byte"
dd if=/dev/zero bs=4096 count=1 2>/dev/null | tr '\0' 'A' > "$TREE/etc/exactly-4096"
cp "$TREE/etc/exactly-4096" "$TREE/etc/block-plus-one"
printf 'B' >> "$TREE/etc/block-plus-one"
dd if=/dev/zero bs=1000 count=37 2>/dev/null | tr '\0' 'C' > "$TREE/bin/multiblock"
cp "$TREE/bin/multiblock" "$TREE/bin/multiblock-twin"
dd if=/dev/zero bs=1000 count=37 2>/dev/null | tr '\0' 'D' > "$TREE/bin/decoy"
ln -s ../etc/motd "$TREE/bin/motd-link"
ln -s /nowhere "$TREE/bin/dangling-link"
echo deep > "$TREE/a/b/c/d/e/f/g/deep.txt"
echo one > "$TREE/etc/conf.d/one"
echo spaced > "$TREE/etc/a file with spaces"
echo dotted > "$TREE/etc/.hidden"
LONG=$(printf 'n%.0s' $(seq 1 255))
echo long > "$TREE/etc/$LONG"
mkdir -p "$TREE/many"
i=0
while [ $i -lt 300 ]; do
echo "file $i" > "$TREE/many/f$i"
i=$((i + 1))
done

136
tools/mutate.sh Normal file
View file

@ -0,0 +1,136 @@
#!/bin/sh
# Mutation-test the SchFS suites: inject a bug, confirm the tests report it,
# restore. A test that cannot fail is worse than no test, because it is
# believed. Run by hand; not part of the normal build.
#
# Every mutation is verified to have actually changed the file before the
# suite runs. A sed pattern that silently stops matching -- after a refactor,
# say -- would otherwise look exactly like a passing mutation test, which is
# the same failure this script exists to catch.
set -e
cd /mnt/d/DEV/Schunix
OBJ=kernel/fs/schfs/obj.c
OBJW=kernel/fs/schfs/objw.c
SPACE=kernel/fs/schfs/space.c
MK=tools/mkschfs.c
cp $OBJ /tmp/obj.orig
cp $OBJW /tmp/objw.orig
cp $SPACE /tmp/space.orig
cp $MK /tmp/mk.orig
restore() {
cp /tmp/obj.orig $OBJ
cp /tmp/objw.orig $OBJW
cp /tmp/space.orig $SPACE
cp /tmp/mk.orig $MK
}
trap restore EXIT
FAILED=0
# try <description> <file> <sed-expression> [suite]
#
# suite is "image" (the default: build an image and diff it against the source
# tree) or "obj" (the in-memory object-layer test). They catch different things:
# the image round-trip cannot see a bug that only shows up after a delete,
# because it never deletes anything.
try() {
desc=$1; file=$2; expr=$3; suite=${4:-image}
restore
before=$(cksum < "$file")
sed -i "$expr" "$file"
after=$(cksum < "$file")
echo "mutation: $desc"
if [ "$before" = "$after" ]; then
echo " BROKEN MUTATION -- the pattern no longer matches anything"
FAILED=1
return
fi
if [ "$suite" = obj ]; then
if ! make build/schfs_objtest > /tmp/mk.log 2>&1; then
echo " rejected at compile time"
return
fi
if ./build/schfs_objtest > /tmp/rt.log 2>&1; then
echo " NOT CAUGHT -- the suite passed with this bug present"
FAILED=1
else
echo " caught: $(grep -aE 'FAIL' /tmp/rt.log | head -1 | sed 's/^ *//')"
fi
return
fi
if ! make build/mkschfs build/schfs_fsck > /tmp/mk.log 2>&1; then
echo " rejected at compile time"
return
fi
if sh tools/schfs_roundtrip.sh ./build/mkschfs ./build/schfs_fsck \
> /tmp/rt.log 2>&1; then
echo " NOT CAUGHT -- the suite passed with this bug present"
FAILED=1
else
echo " caught: $(grep -aE 'FAIL|Only in|differ|loop' /tmp/rt.log | head -1 | sed 's/^ *//')"
fi
}
# Reads run past end-of-file, so every file comes back padded.
try "file_read ignores the end-of-file clamp" $OBJ \
's|if (off + n > in.size)|if (0)|'
# The dirent walk skips forward too far, losing entries in any packed list.
try "dirent_walk advances past the next entry" $OBJ \
's| off += step;| off += step + 8;|'
# Directory lookup returns the neighbouring object.
try "dir_lookup returns the wrong objectid" $OBJ \
's| f->ino = d->objectid;| f->ino = d->objectid + 1;|'
# Every extent points one block past its data.
#
# Note this replaced an earlier mutation that shortened the extent by a block.
# That one was NOT caught, and correctly so: an extent covering too little just
# leaves the tail unmapped, and schfs_file_append then allocates it separately,
# so the file still reads back byte for byte. The design absorbs it. Pointing
# at the wrong block cannot be absorbed.
try "extents point one block past their data" $OBJW \
's| e.disk_block = first;| e.disk_block = first + 1;|'
# Copy-on-write splits an extent into head, replacement and tail. Losing the
# tail leaves everything after the rewritten block unmapped, so it silently
# reads back as zeros -- data loss that looks exactly like a sparse file.
try "an overwrite drops the tail of the extent it split" $OBJW \
's| if (fblk + 1 < start + e.nblocks) {| if (0) {|' obj
# Truncating down and growing again must read zeros. Skipping the zeroing of
# the partial last block hands back whatever the file used to hold there --
# which, across a delete and a reallocation, is somebody else's data.
try "truncate leaves stale bytes in the last partial block" $OBJW \
's| memset((char \*)dblk + tail, 0, SCHFS_BLKSIZE - tail);| /* mutated */;|' obj
# Deferred frees never rejoin the free map, so every delete leaks. Nothing that
# only reads or only writes can see this; it takes a test that deletes
# everything and then checks the free count.
try "committed frees are never returned to the free map" $SPACE \
's| inuse\[w\] &= ~pend\[w\];| /* mutated */;|' obj
# Hard links are not counted, so dedup is indistinguishable from duplication
# and only the explicit nlink assertion can tell the difference.
try "hard links do not bump nlink" $MK \
's|^ in.nlink++;| /* mutated */;|'
# Data checksums cover only the head of a block, so tail corruption is missed.
try "data checksum covers only the first 16 bytes" $OBJW \
's|uint32_t c = schfs_crc32c(0, data, SCHFS_BLKSIZE);|uint32_t c = schfs_crc32c(0, data, 16);|'
restore
make build/mkschfs build/schfs_fsck > /dev/null 2>&1
echo
if [ $FAILED -eq 0 ]; then
echo "all mutations were caught"
else
echo "SOME MUTATIONS WERE NOT CAUGHT -- the suite has a blind spot"
exit 1
fi

24
tools/padstage2.pl Normal file
View file

@ -0,0 +1,24 @@
#!/usr/bin/perl
# Zero-pads the stage2 bootloader binary out to a fixed size (STAGE2_SECTORS
# * 512 bytes), so the kernel's on-disk starting sector is predictable
# regardless of stage2's actual compiled size. Errors out if stage2 has
# grown past that budget instead of silently truncating it.
my $size = $ARGV[1] || die "usage: padstage2.pl <file> <size-in-bytes>\n";
open(F, $ARGV[0]) || die "open $ARGV[0]: $!";
my $n = sysread(F, my $buf, $size + 1);
close(F);
if ($n > $size) {
print STDERR "stage2 too large: $n bytes (max $size)\n";
exit 1;
}
print STDERR "stage2 is $n bytes (max $size)\n";
$buf .= "\0" x ($size - $n);
open(F, ">$ARGV[0]") || die "open >$ARGV[0]: $!";
print F $buf;
close(F);

36
tools/pr.pl Normal file
View file

@ -0,0 +1,36 @@
#!/usr/bin/perl
use POSIX qw(strftime);
if($ARGV[0] eq "-h"){
shift @ARGV;
$h = $ARGV[0];
shift @ARGV;
}else{
$h = $ARGV[0];
}
$page = 0;
$now = strftime "%b %e %H:%M %Y", localtime;
@lines = <>;
for($i=0; $i<@lines; $i+=50){
print "\n\n";
++$page;
print "$now $h Page $page\n";
print "\n\n";
for($j=$i; $j<@lines && $j<$i +50; $j++){
$lines[$j] =~ s!//DOC.*!!;
print $lines[$j];
}
for(; $j<$i+50; $j++){
print "\n";
}
$sheet = "";
if($lines[$i] =~ /^([0-9][0-9])[0-9][0-9] /){
$sheet = "Sheet $1";
}
print "\n\n";
print "$sheet\n";
print "\n\n";
}

185
tools/qrcheck.py Normal file
View file

@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Cross-check kernel/core/qr.c against independent implementations.
The self-checks inside tools/qrtest.c verify what has a local notion of
correctness -- table arithmetic, the Reed-Solomon generator polynomials, whether
a finder pattern is where it should be. They cannot verify the parts that only
mean something to a decoder: zigzag placement order, mask selection, and block
interleaving. All three have the same failure mode, which is a symbol that looks
exactly like a QR code and is noise.
Two oracles, in order of authority:
1. **A real decoder** (OpenCV). Does the symbol read back as the exact bytes
that went in? This is the only test that matches what the feature is for --
somebody pointing a phone at a crashed machine -- and it is the one that
decides pass or fail.
2. **A second encoder** (segno), matrix compared module for module. Stricter
than decoding when it agrees, because it pins down the exact mask and
placement rather than merely something readable. Applied only to payloads
that exactly fill their version, for the reason below.
segno adds a spurious zero pad codeword to every byte-mode symbol. Its
write_padding_bits() does `buff.extend([0] * (8 - (length % 8)))`, which
emits a whole byte when the stream is already on a codeword boundary -- and
in byte mode the stream is always 8L+16 bits after the terminator, so it
always is. ISO/IEC 18004 makes that conditional ("if the bit stream length is
such that it does not end at a codeword boundary"), and segno quotes exactly
that sentence above the line. It is harmless -- decoders read `count` bytes
and ignore padding -- but it means the two encoders cannot agree bit-for-bit
on any payload that needs padding, so those are compared by decoding only.
pip install --target <dir> opencv-python-headless numpy segno
PYTHONPATH=<dir> python tools/qrcheck.py
Requires build/tools/qrtest (make qrtest). Both libraries are optional; whatever
is installed gets used.
"""
import os
import subprocess
import sys
# Normalised and absolute: Windows CreateProcess rejects a relative path written
# with forward slashes, even when the file is plainly there.
QRTEST = os.path.abspath(os.path.join("build", "tools",
"qrtest.exe" if os.name == "nt" else "qrtest"))
try:
import cv2
import numpy as np
except ImportError:
cv2 = None
try:
import segno
except ImportError:
segno = None
if cv2 is None and segno is None:
sys.exit("qrcheck: needs opencv-python-headless and/or segno")
if not os.path.exists(QRTEST):
sys.exit(f"qrcheck: {QRTEST} not found -- run `make qrtest`")
def ours(text):
"""Our encoder's matrix, as a list of strings of '0'/'1'."""
out = subprocess.run([QRTEST, "-m", text], capture_output=True, text=True)
if out.returncode != 0:
raise RuntimeError(f"qrtest failed on {text!r}")
return out.stdout.split()
def our_mask(m):
"""The mask our encoder chose, read back out of the format bits.
Read from the symbol rather than reported by it, because the point is to
check what was actually written.
"""
bits = 0
for i in range(15):
if i <= 5: b = int(m[i][8])
elif i == 6: b = int(m[7][8])
elif i == 7: b = int(m[8][8])
elif i == 8: b = int(m[8][7])
else: b = int(m[8][14 - i])
bits |= b << i
return ((bits ^ 0x5412) >> 10) & 7
def render(m, scale, border):
n = len(m)
side = (n + 2 * border) * scale
img = np.full((side, side), 255, np.uint8)
for y in range(n):
for x in range(n):
if m[y][x] == "1":
img[(y + border) * scale:(y + border + 1) * scale,
(x + border) * scale:(x + border + 1) * scale] = 0
return img
# OpenCV's detector is not a reference implementation and does fail on symbols
# that are perfectly valid -- sensitive to scale and quiet-zone width in ways
# that have nothing to do with the encoding. Several renderings are tried before
# concluding anything, and a payload that still will not decode is re-tested
# against segno's symbol for the same input: if that fails too, the detector is
# what is at fault, not the encoder. Without that second step this check reports
# false failures, which is worse than not running it.
RENDERINGS = [(8, 4), (6, 6), (8, 6), (4, 4), (12, 6), (16, 4)]
def decode(m):
for scale, border in RENDERINGS:
text, _, _ = cv2.QRCodeDetector().detectAndDecode(render(m, scale, border))
if text:
return text
return ""
# Byte capacity per version: total - ecc*blocks - 2. Mirrors the table in qr.c;
# the exact-fill cases below are built from it, and a disagreement would show up
# as a version mismatch rather than passing quietly.
CAPACITY = {1: 17, 2: 32, 3: 53, 4: 78, 5: 106, 6: 134}
alpha = lambda n: "".join(chr(65 + i % 26) for i in range(n))
CASES = [
"HELLO",
"SCHUNIX",
"a",
"0123456789",
"SCHUNIX PANIC\ntrap 14 err 4 cpu 0\nrip ffffffff80104e2a",
bytes(range(1, 40)).decode("latin-1"), # non-printable payload
]
# One payload that exactly fills each version, and one just under, so both the
# padded and unpadded paths are covered at every version -- including 6, which
# is the only one with more than one Reed-Solomon block.
for v, cap in CAPACITY.items():
CASES.append(alpha(cap))
CASES.append(alpha(cap - 1))
fails = 0
print("decode (OpenCV)" if cv2 else "decode -- skipped, no opencv")
for text in CASES:
if not cv2:
break
m = ours(text)
v = (len(m) - 17) // 4
got = decode(m)
if got == text:
print(f" [ok] v{v} {len(text):3d}B")
continue
# Before blaming the encoder, check whether an independently generated
# symbol for the same payload decodes either.
if segno and not decode([
"".join(str(x) for x in row)
for row in segno.make(text.encode("latin-1"), error="l", version=v,
boost_error=False, mode="byte").matrix]):
print(f" [detector] v{v} {len(text):3d}B -- segno's symbol does not "
f"decode either; OpenCV's limitation, not ours")
continue
fails += 1
print(f" [FAIL] v{v} {len(text):3d}B decoded {got[:40]!r}")
print("\nmatrix vs segno (exact-fill payloads only)" if segno else "\nsegno -- skipped")
for v, cap in CAPACITY.items():
if not segno:
break
text = alpha(cap)
m = ours(text)
mask = our_mask(m)
q = segno.make(text.encode("latin-1"), error="l", version=v,
mask=mask, boost_error=False, mode="byte")
ref = ["".join(str(x) for x in row) for row in q.matrix]
ok = m == ref
fails += not ok
n = len(m)
diff = "" if ok else f" {sum(1 for a, b in zip(''.join(m), ''.join(ref)) if a != b)}/{n*n} differ"
print(f" [{'ok' if ok else 'FAIL'}] v{v} mask {mask} {cap:3d}B{diff}")
print()
print("qrcheck: FAILED" if fails else "qrcheck: passed")
sys.exit(1 if fails else 0)

354
tools/qrtest.c Normal file
View file

@ -0,0 +1,354 @@
// Host harness for kernel/core/qr.c.
//
// The QR encoder cannot be unit-tested against itself in any meaningful way: a
// symbol with a mistyped table entry is still a plausible-looking grid of black
// squares. The only real test is whether a decoder reads it back, so this builds
// the same encoder on the host and writes a PNG to scan.
//
// make qrtest a sample crash report
// build/tools/qrtest out.png "text" arbitrary payload
//
// It also self-checks the parts that *can* be checked mechanically -- the
// version table's internal consistency, and the Reed-Solomon remainder against
// a known vector -- so a transcription error is caught before anyone reaches
// for a phone.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define QR_HOST
#include "../kernel/core/qr.c"
// --- Minimal PNG writer ----------------------------------------------------
//
// Uncompressed (stored) deflate blocks, so there is no zlib dependency for a
// tool whose entire job is to produce one small image. A stored block costs
// five bytes of header per 65535 bytes of data, which for a 41-module symbol is
// irrelevant.
static unsigned long crc_table[256];
static void
crc_init(void)
{
for (int n = 0; n < 256; n++) {
unsigned long c = (unsigned long)n;
for (int k = 0; k < 8; k++)
c = (c & 1) ? 0xEDB88320UL ^ (c >> 1) : c >> 1;
crc_table[n] = c;
}
}
static unsigned long
crc(const unsigned char *buf, int len)
{
unsigned long c = 0xFFFFFFFFUL;
for (int n = 0; n < len; n++)
c = crc_table[(c ^ buf[n]) & 0xFF] ^ (c >> 8);
return c ^ 0xFFFFFFFFUL;
}
static void
be32(unsigned char *p, unsigned long v)
{
p[0] = (unsigned char)(v >> 24); p[1] = (unsigned char)(v >> 16);
p[2] = (unsigned char)(v >> 8); p[3] = (unsigned char)v;
}
static void
put_chunk(FILE *f, const char *type, const unsigned char *data, int len)
{
unsigned char hdr[4];
be32(hdr, (unsigned long)len);
fwrite(hdr, 1, 4, f);
unsigned char *buf = malloc((size_t)len + 4);
memcpy(buf, type, 4);
if (len)
memcpy(buf + 4, data, (size_t)len);
fwrite(buf, 1, (size_t)len + 4, f);
be32(hdr, crc(buf, len + 4));
fwrite(hdr, 1, 4, f);
free(buf);
}
// Write the symbol as a PNG, `scale` pixels per module, with the 4-module quiet
// zone the standard requires. The quiet zone is not decoration: without it many
// decoders will not find the symbol at all.
static int
write_png(const char *path, const struct qr *q, int scale, int quiet)
{
int mods = q->size + 2 * quiet;
int w = mods * scale, h = w;
// Greyscale, 8-bit, one filter byte per row.
int stride = w + 1;
unsigned char *raw = malloc((size_t)stride * h);
for (int y = 0; y < h; y++) {
unsigned char *row = raw + (size_t)y * stride;
row[0] = 0; // filter: none
int my = y / scale - quiet;
for (int x = 0; x < w; x++) {
int mx = x / scale - quiet;
int dark = (mx >= 0 && my >= 0 && mx < q->size && my < q->size)
? q->mod[my][mx] : 0;
row[1 + x] = dark ? 0x00 : 0xFF;
}
}
int rawlen = stride * h;
// zlib stream: header, stored deflate blocks, adler32.
int cap = rawlen + (rawlen / 65535 + 1) * 5 + 6;
unsigned char *z = malloc((size_t)cap);
int zn = 0;
z[zn++] = 0x78; z[zn++] = 0x01;
int off = 0;
while (off < rawlen) {
int n = rawlen - off;
if (n > 65535) n = 65535;
z[zn++] = (off + n >= rawlen) ? 1 : 0;
z[zn++] = (unsigned char)(n & 0xFF);
z[zn++] = (unsigned char)(n >> 8);
z[zn++] = (unsigned char)(~n & 0xFF);
z[zn++] = (unsigned char)((~n >> 8) & 0xFF);
memcpy(z + zn, raw + off, (size_t)n);
zn += n;
off += n;
}
unsigned long a = 1, b = 0;
for (int i = 0; i < rawlen; i++) {
a = (a + raw[i]) % 65521;
b = (b + a) % 65521;
}
be32(z + zn, (b << 16) | a);
zn += 4;
FILE *f = fopen(path, "wb");
if (!f) { perror(path); return -1; }
fwrite("\x89PNG\r\n\x1a\n", 1, 8, f);
unsigned char ihdr[13];
be32(ihdr, (unsigned long)w);
be32(ihdr + 4, (unsigned long)h);
ihdr[8] = 8; ihdr[9] = 0; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
put_chunk(f, "IHDR", ihdr, 13);
put_chunk(f, "IDAT", z, zn);
put_chunk(f, "IEND", NULL, 0);
fclose(f);
free(raw);
free(z);
return 0;
}
// --- Self-checks -----------------------------------------------------------
static int failures;
static void
check(int ok, const char *what)
{
printf(" [%s] %s\n", ok ? "ok" : "FAIL", what);
if (!ok) failures++;
}
// The version table is the one part of qr.c that is transcription rather than
// reasoning, so check what can be checked: block sizes must divide evenly, and
// the symbol must have room for exactly the codewords claimed.
static void
check_tables(void)
{
printf("version table\n");
for (int v = 1; v <= QR_MAX_VERSION; v++) {
int total = vinfo[v].total;
int data = total - vinfo[v].ecc * vinfo[v].blocks;
char msg[128];
snprintf(msg, sizeof msg, "v%d: %d data codewords divide into %d blocks",
v, data, vinfo[v].blocks);
check(data > 0 && data % vinfo[v].blocks == 0, msg);
// Count the modules a symbol of this version actually leaves for data,
// by building one and asking. If the table's codeword count disagrees
// with the geometry, one of the two is wrong.
struct qr q;
memset(&q, 0, sizeof q);
q.size = 17 + 4 * v;
draw_function_patterns(&q);
int free_modules = 0;
for (int y = 0; y < q.size; y++)
for (int x = 0; x < q.size; x++)
if (!q.fn[y][x]) free_modules++;
snprintf(msg, sizeof msg,
"v%d: %d free modules holds %d codewords (%d spare bits)",
v, free_modules, total, free_modules - total * 8);
check(free_modules >= total * 8 && free_modules - total * 8 < 8, msg);
}
}
// A known Reed-Solomon vector. "HELLO WORLD" as a version 1-M byte-mode symbol
// has data codewords 0x40 0xB3 0x42 ... ; rather than depend on remembering the
// whole thing, check the standard's own worked example: the generator
// polynomial for degree 10 begins with the coefficients below.
static void
check_rs(void)
{
printf("reed-solomon\n");
unsigned char g[30];
rs_divisor(10, g);
// Generator for 10 EC codewords, highest power first, leading 1 implicit.
static const unsigned char want10[10] = {
216, 194, 159, 111, 199, 94, 95, 113, 157, 193
};
check(memcmp(g, want10, 10) == 0, "degree-10 generator polynomial");
rs_divisor(7, g);
static const unsigned char want7[7] = { 127, 122, 154, 164, 11, 68, 117 };
check(memcmp(g, want7, 7) == 0, "degree-7 generator polynomial");
// Multiplication is the primitive everything else rests on.
check(gmul(0, 5) == 0 && gmul(1, 5) == 5, "gf(256) identity and zero");
check(gmul(0x80, 0x02) == 0x1D, "gf(256) reduction by 0x11D");
}
static void
check_encode(void)
{
printf("encode\n");
struct qr q;
check(qr_encode(&q, (const unsigned char *)"HELLO", 5) == 0, "short payload encodes");
check(q.size == 21, "and picks version 1");
unsigned char big[QR_MAX_BYTES];
memset(big, 'A', sizeof big);
check(qr_encode(&q, big, QR_MAX_BYTES) == 0, "maximum payload encodes");
check(q.size == 41, "and picks version 6");
check(qr_encode(&q, big, QR_MAX_BYTES + 1) == -1, "one byte over is refused");
// Every function module must still be a function module after masking --
// i.e. masking must not have touched the finders.
qr_encode(&q, (const unsigned char *)"test", 4);
check(q.mod[0][0] && q.mod[6][6] && !q.mod[5][5], "finder pattern intact");
check(q.mod[q.size - 8][8] == 1, "dark module set");
// Row 6 alternates with the *even* coordinates dark, so the first timing
// module clear of the separator (x=8) is dark and x=9 is light. Asserting
// this the other way round was the one self-check that failed on the first
// run, and the test was what was wrong -- worth keeping the orientation
// written down.
check(q.mod[6][8] == 1 && q.mod[6][9] == 0, "horizontal timing pattern");
check(q.mod[8][6] == 1 && q.mod[9][6] == 0, "vertical timing pattern");
}
int
main(int argc, char **argv)
{
crc_init();
// -q suppresses the terminal rendering. It is on by default because a
// phone can read the symbol straight off the terminal without anyone
// opening a file, but it is 40 lines of escape codes and sometimes in the
// way.
int quiet = 0;
if (argc > 1 && strcmp(argv[1], "-q") == 0) {
quiet = 1;
argc--; argv++;
}
// -m dumps the raw module matrix and nothing else, for comparison against
// an independent encoder. This is the check that actually matters: a symbol
// can pass every self-check above and still be unreadable if the zigzag,
// the masking or the block interleave is subtly wrong, and none of those
// have a plausible-looking failure mode -- they all just produce noise that
// resembles a QR code. tools/qrcheck.py drives this.
// -f dumps the function-pattern map alongside the modules, so a checker can
// run the zigzag backwards and recover the codewords. Knowing *which*
// codeword two encoders disagree about is the difference between a bug you
// can see and one you can only stare at.
if (argc > 2 && strcmp(argv[1], "-f") == 0) {
struct qr q;
int n = (int)strlen(argv[2]);
if (n > QR_MAX_BYTES || qr_encode(&q, (const unsigned char *)argv[2], n) != 0)
return 1;
for (int y = 0; y < q.size; y++) {
for (int x = 0; x < q.size; x++)
putchar(q.fn[y][x] ? '1' : '0');
putchar('\n');
}
return 0;
}
if (argc > 2 && strcmp(argv[1], "-m") == 0) {
struct qr q;
int n = (int)strlen(argv[2]);
if (n > QR_MAX_BYTES || qr_encode(&q, (const unsigned char *)argv[2], n) != 0)
return 1;
for (int y = 0; y < q.size; y++) {
for (int x = 0; x < q.size; x++)
putchar(q.mod[y][x] ? '1' : '0');
putchar('\n');
}
return 0;
}
const char *out = argc > 1 ? argv[1] : "build/qr.png";
const char *text = argc > 2 ? argv[2] :
"SCHUNIX PANIC\n"
"build 8f2a91c4\n"
"trap 14 err 4 cpu 0\n"
"rip ffffffff80104e2a\n"
"cr2 0000000000000018\n"
"pid 7 schinit";
check_tables();
check_rs();
check_encode();
struct qr q;
int len = (int)strlen(text);
if (len > QR_MAX_BYTES) {
fprintf(stderr, "qrtest: payload is %d bytes, max %d\n", len, QR_MAX_BYTES);
return 1;
}
if (qr_encode(&q, (const unsigned char *)text, len) != 0) {
fprintf(stderr, "qrtest: encode failed\n");
return 1;
}
if (write_png(out, &q, 8, 4) != 0)
return 1;
printf("\n%d bytes -> version %d (%dx%d modules) -> %s\n",
len, (q.size - 17) / 4, q.size, q.size, out);
// Also dump it to the terminal, where a phone can read it directly off the
// screen without opening the file. Two spaces per module because a terminal
// cell is about twice as tall as it is wide, and a squashed symbol will not
// scan.
if (!quiet) {
printf("\n");
for (int i = 0; i < 2; i++) {
printf(" ");
for (int x = 0; x < q.size + 8; x++) printf("\033[107m \033[0m");
printf("\n");
}
for (int y = 0; y < q.size; y++) {
printf(" \033[107m \033[0m");
for (int x = 0; x < q.size; x++)
printf("%s \033[0m", q.mod[y][x] ? "\033[40m" : "\033[107m");
printf("\033[107m \033[0m\n");
}
for (int i = 0; i < 2; i++) {
printf(" ");
for (int x = 0; x < q.size + 8; x++) printf("\033[107m \033[0m");
printf("\n");
}
}
printf("\n%s\n", failures ? "SELF-CHECKS FAILED" : "self-checks passed");
return failures ? 1 : 0;
}

720
tools/schfs_fsck.c Normal file
View file

@ -0,0 +1,720 @@
// SPDX-License-Identifier: Apache-2.0
//
// schfs_fsck — inspect, verify and extract a SchFS image.
//
// "fsck" is a slight misnomer: copy-on-write means there is nothing to repair.
// A crash leaves either the old tree or the new one, so a volume is either
// consistent or it has no valid uberblock at all. What this does is *check* --
// and, just as usefully, read an image back out so its contents can be compared
// against whatever produced it.
//
// schfs_fsck img --info uberblock ring and volume summary
// schfs_fsck img --check full structural verification
// schfs_fsck img --ls PATH list a directory
// schfs_fsck img --stat PATH one object's metadata
// schfs_fsck img --cat PATH a file's contents to stdout
// schfs_fsck img --extract DIR the whole tree onto the host
// schfs_fsck img --tree every path in the volume
//
// --extract is what makes the round-trip test possible, and the reason this
// reads the image through the same code the kernel will use: if that code is
// wrong, the extracted tree differs from the source tree and diff says so.
#define _FILE_OFFSET_BITS 64
#define SCHFS_HOSTED 1
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <errno.h>
#include <sys/stat.h>
#include <sys/types.h>
#include "../kernel/fs/schfs/btree.c"
#include "../kernel/fs/schfs/space.c"
#include "../kernel/fs/schfs/vol.c"
#include "../kernel/fs/schfs/obj.c"
/* ------------------------------------------------------------------ */
struct imgdev {
FILE *f;
uint64_t nblocks;
};
static struct imgdev IMG;
static int
img_read(void *ctx, uint64_t b, void *buf)
{
struct imgdev *d = ctx;
if (b >= d->nblocks)
return -1;
if (fseeko(d->f, (off_t)b * SCHFS_BLKSIZE, SEEK_SET) != 0)
return -1;
if (fread(buf, 1, SCHFS_BLKSIZE, d->f) != SCHFS_BLKSIZE)
return -1;
return 0;
}
static int
img_write(void *ctx, uint64_t b, const void *buf)
{
(void)ctx; (void)b; (void)buf;
return -1; /* this tool never writes */
}
static uint64_t img_alloc(void *ctx) { (void)ctx; return 0; }
static void img_nofree(void *ctx, uint64_t b) { (void)ctx; (void)b; }
// Pages for the allocation map --check derives. Nothing is written back; this
// is a check on the map, not a use of it.
static void *img_palloc(void) { return calloc(1, SCHFS_BLKSIZE); }
static struct schfs_vol VOL;
static char CURBLK[SCHFS_BLKSIZE];
static char DATABLK[SCHFS_BLKSIZE];
// Read-only, so only the first of these is ever touched — but a volume owns all
// three for its whole life, and handing it two null pointers would turn a
// mistake into a crash rather than a compile error.
static char CTX_A[SCHFS_BLKSIZE];
static char CTX_B[SCHFS_BLKSIZE];
static char CTX_C[SCHFS_BLKSIZE];
static struct schfs_ctx CTX = { CTX_A, CTX_B, CTX_C };
static const char *
typename(uint16_t t)
{
switch (t) {
case SCHFS_T_FILE: return "file";
case SCHFS_T_DIR: return "dir";
case SCHFS_T_DEV: return "dev";
case SCHFS_T_SYMLINK: return "symlink";
default: return "?";
}
}
/* ------------------------------------------------------------------ */
/* --info */
/* ------------------------------------------------------------------ */
static int
do_info(void)
{
printf("uberblock ring (%d slots from block %d)\n",
SCHFS_UBER_COUNT, SCHFS_UBER_START);
int valid = 0, best = -1;
uint64_t bestgen = 0;
for (int i = 0; i < SCHFS_UBER_COUNT; i++) {
struct schfs_uberblock *u = (struct schfs_uberblock *)CTX_A;
if (img_read(&IMG, SCHFS_UBER_START + i, u) != 0)
continue;
if (u->magic != SCHFS_MAGIC)
continue;
uint32_t stored = u->crc;
u->crc = 0;
uint32_t calc = schfs_crc32c(0, u, sizeof(*u));
u->crc = stored;
if (calc != stored) {
printf(" slot %3d: magic ok, CHECKSUM BAD (generation %llu)\n",
i, (unsigned long long)u->generation);
continue;
}
valid++;
if (best < 0 || u->generation > bestgen) {
bestgen = u->generation;
best = i;
}
}
printf(" %d slot%s valid; newest is slot %d at generation %llu\n\n",
valid, valid == 1 ? "" : "s", best, (unsigned long long)bestgen);
printf("volume\n");
printf(" label %s\n", VOL.label);
printf(" uuid ");
for (int i = 0; i < 16; i++)
printf("%02x%s", VOL.uuid[i], (i == 3 || i == 5 || i == 7 || i == 9) ? "-" : "");
printf("\n");
printf(" block size %d bytes\n", SCHFS_BLKSIZE);
printf(" total blocks %llu (%llu KB)\n",
(unsigned long long)VOL.total_blocks,
(unsigned long long)VOL.total_blocks * SCHFS_BLKSIZE / 1024);
printf(" free blocks %llu (%llu KB)\n",
(unsigned long long)VOL.free_blocks,
(unsigned long long)VOL.free_blocks * SCHFS_BLKSIZE / 1024);
printf(" tree root block %llu, generation %llu\n",
(unsigned long long)VOL.tree.root,
(unsigned long long)VOL.tree.generation);
printf(" next object id %llu\n", (unsigned long long)VOL.next_objectid);
printf(" root object id %llu\n", (unsigned long long)VOL.root_objectid);
return 0;
}
/* ------------------------------------------------------------------ */
/* --check */
/* ------------------------------------------------------------------ */
struct tally {
long inodes, dirents, extents, csums, other;
long long bytes;
int bad;
};
static int
count_rec(const struct schfs_key *k, const void *val, unsigned len, void *arg)
{
struct tally *t = arg;
(void)val;
switch (k->type) {
case SCHFS_KEY_INODE:
t->inodes++;
if (len != sizeof(struct schfs_inode)) {
fprintf(stderr, " object %llu: inode record is %u bytes, expected %zu\n",
(unsigned long long)k->objectid, len,
sizeof(struct schfs_inode));
t->bad++;
} else {
t->bytes += ((const struct schfs_inode *)val)->size;
}
break;
case SCHFS_KEY_DIRENT: t->dirents++; break;
case SCHFS_KEY_EXTENT:
t->extents++;
if (len != sizeof(struct schfs_extent)) {
fprintf(stderr, " object %llu: extent record is %u bytes\n",
(unsigned long long)k->objectid, len);
t->bad++;
}
break;
case SCHFS_KEY_CSUM: t->csums++; break;
default: t->other++; break;
}
return 0;
}
/* Verify every data block against its stored checksum, by reading every file
in full. This is the part a metadata-only check would miss entirely. */
static long files_checked, blocks_checked;
// Chain of object ids from the root down to whatever is being checked.
//
// A directory that is its own ancestor makes any recursive walk run until the
// stack runs out, and a segfault is a terrible way to be told the filesystem
// has a loop. Directories cannot be hard-linked, so a repeat in this chain is
// always a fault -- and the first version of mkschfs produced exactly one, by
// handing a subdirectory the root's object id.
#define MAX_DEPTH 64
static uint64_t chain[MAX_DEPTH];
static int chain_n;
static int
chain_push(uint64_t ino, const char *path)
{
for (int i = 0; i < chain_n; i++)
if (chain[i] == ino) {
fprintf(stderr, " %s: directory loop -- object %llu is its own "
"ancestor\n", path, (unsigned long long)ino);
return -1;
}
if (chain_n >= MAX_DEPTH) {
fprintf(stderr, " %s: directory nesting deeper than %d\n",
path, MAX_DEPTH);
return -1;
}
chain[chain_n++] = ino;
return 0;
}
static void chain_pop(void) { if (chain_n) chain_n--; }
static int check_dir(uint64_t ino, const char *path);
static int
check_entry(uint64_t ino, uint16_t type, const char *name, unsigned namelen,
void *arg)
{
const char *parent = arg;
char path[8192];
snprintf(path, sizeof(path), "%s%s%.*s",
parent, strcmp(parent, "/") == 0 ? "" : "/", (int)namelen, name);
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
struct schfs_inode in;
if (schfs_inode_get(&VOL, &c, ino, &in) != 0) {
fprintf(stderr, " %s: entry points at object %llu, which has no inode\n",
path, (unsigned long long)ino);
return -1;
}
if (in.type != type)
fprintf(stderr, " %s: dirent says %s, inode says %s\n",
path, typename(type), typename(in.type));
if (in.type == SCHFS_T_DIR)
return check_dir(ino, path);
uint64_t off = 0;
while (off < in.size) {
// A full block is requested every time, even at the tail. Clamping
// here instead would mean nothing ever tests that schfs_file_read
// stops at end-of-file -- and the kernel's read(2) will depend on
// exactly that, since a real program just asks for a bufferful.
char buf[SCHFS_BLKSIZE];
unsigned want = sizeof(buf);
long got = schfs_file_read(&VOL, &c, ino, off, buf, want, DATABLK);
if (got <= 0) {
fprintf(stderr, " %s: read failed at offset %llu (%ld)\n",
path, (unsigned long long)off, got);
return -1;
}
off += (uint64_t)got;
blocks_checked++;
}
files_checked++;
return 0;
}
static int
check_dir(uint64_t ino, const char *path)
{
if (chain_push(ino, path) != 0)
return -1;
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
int r = schfs_dir_iterate(&VOL, &c, ino, check_entry, (void *)path);
chain_pop();
return r;
}
static int
do_check(void)
{
printf("checking %s\n", VOL.label);
if (schfs_btree_check(&VOL.tree) != 0) {
fprintf(stderr, " FAIL: tree structure is invalid\n");
return 1;
}
printf(" [ok] tree structure, checksums and key ordering\n");
struct tally t;
memset(&t, 0, sizeof(t));
if (schfs_btree_iterate(&VOL.tree, count_rec, &t) != 0) {
fprintf(stderr, " FAIL: could not walk every record\n");
return 1;
}
if (t.bad) {
fprintf(stderr, " FAIL: %d malformed records\n", t.bad);
return 1;
}
printf(" [ok] %ld inodes, %ld dirent items, %ld extents, %ld data checksums\n",
t.inodes, t.dirents, t.extents, t.csums);
if (check_dir(VOL.root_objectid, "/") != 0) {
fprintf(stderr, " FAIL: walking the directory tree found a problem\n");
return 1;
}
printf(" [ok] %ld files read end to end, every data block matched its checksum\n",
files_checked);
// Rebuild the allocation map the way a mount would, and hold it against the
// count the writer left in the uberblock. The map is derived rather than
// stored, so nothing else would ever notice if the scan and the writer
// disagreed — right up until a mounted volume handed out a block that was
// still in use.
if (schfs_space_init(&VOL.space, VOL.total_blocks, img_palloc) != 0) {
fprintf(stderr, " FAIL: could not build an allocation map\n");
return 1;
}
if (schfs_space_scan(&VOL) != 0) {
fprintf(stderr, " FAIL: could not walk the tree to derive free space\n");
return 1;
}
if (VOL.space.free_blocks != VOL.free_blocks) {
fprintf(stderr, " FAIL: uberblock says %llu blocks free, walking the "
"tree says %llu\n",
(unsigned long long)VOL.free_blocks,
(unsigned long long)VOL.space.free_blocks);
return 1;
}
printf(" [ok] free space derived from the tree matches the uberblock "
"(%llu of %llu blocks in use)\n",
(unsigned long long)(VOL.total_blocks - VOL.space.free_blocks),
(unsigned long long)VOL.total_blocks);
return 0;
}
/* ------------------------------------------------------------------ */
/* --ls / --stat / --cat / --tree */
/* ------------------------------------------------------------------ */
static int
print_entry(uint64_t ino, uint16_t type, const char *name, unsigned namelen,
void *arg)
{
(void)arg;
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
struct schfs_inode in;
if (schfs_inode_get(&VOL, &c, ino, &in) != 0) {
printf(" ? %.*s\n", (int)namelen, name);
return 0;
}
printf(" %-7s %6llu %4o %2u links %.*s\n",
typename(type), (unsigned long long)in.size, in.mode & 07777,
in.nlink, (int)namelen, name);
return 0;
}
static uint64_t
resolve(const char *path)
{
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
uint64_t ino = schfs_path_lookup(&VOL, &c, path);
if (ino == 0)
fprintf(stderr, "schfs_fsck: %s: no such file or directory\n", path);
return ino;
}
static int
do_ls(const char *path)
{
uint64_t ino = resolve(path);
if (!ino)
return 1;
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
printf("%s\n", path);
return schfs_dir_iterate(&VOL, &c, ino, print_entry, NULL) != 0;
}
static int
do_stat(const char *path)
{
uint64_t ino = resolve(path);
if (!ino)
return 1;
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
struct schfs_inode in;
if (schfs_inode_get(&VOL, &c, ino, &in) != 0) {
fprintf(stderr, "schfs_fsck: %s: inode unreadable\n", path);
return 1;
}
printf("path: %s\n", path);
printf("objectid: %llu\n", (unsigned long long)ino);
printf("type: %s\n", typename(in.type));
printf("size: %llu\n", (unsigned long long)in.size);
printf("blocks: %llu\n", (unsigned long long)in.blocks);
printf("mode: %04o\n", in.mode & 07777);
printf("nlink: %u\n", in.nlink);
printf("uid: %u\n", in.uid);
printf("gid: %u\n", in.gid);
printf("mtime: %llu\n", (unsigned long long)in.mtime);
printf("parent: %llu\n", (unsigned long long)in.parent);
if (in.type == SCHFS_T_DEV)
printf("rdev: %u:%u\n", in.rdev_major, in.rdev_minor);
return 0;
}
static int
do_cat(const char *path)
{
uint64_t ino = resolve(path);
if (!ino)
return 1;
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
struct schfs_inode in;
if (schfs_inode_get(&VOL, &c, ino, &in) != 0)
return 1;
uint64_t off = 0;
while (off < in.size) {
// A full block is requested every time, even at the tail. Clamping
// here instead would mean nothing ever tests that schfs_file_read
// stops at end-of-file -- and the kernel's read(2) will depend on
// exactly that, since a real program just asks for a bufferful.
char buf[SCHFS_BLKSIZE];
unsigned want = sizeof(buf);
long got = schfs_file_read(&VOL, &c, ino, off, buf, want, DATABLK);
if (got <= 0) {
fprintf(stderr, "schfs_fsck: %s: read error at %llu\n",
path, (unsigned long long)off);
return 1;
}
fwrite(buf, 1, (size_t)got, stdout);
off += (uint64_t)got;
}
return 0;
}
static int tree_walk(uint64_t ino, const char *path);
static int
tree_entry(uint64_t ino, uint16_t type, const char *name, unsigned namelen,
void *arg)
{
const char *parent = arg;
char path[8192];
snprintf(path, sizeof(path), "%s%s%.*s",
parent, strcmp(parent, "/") == 0 ? "" : "/", (int)namelen, name);
printf("%-8s %s\n", typename(type), path);
if (type == SCHFS_T_DIR)
return tree_walk(ino, path);
return 0;
}
static int
tree_walk(uint64_t ino, const char *path)
{
if (chain_push(ino, path) != 0)
return -1;
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
int r = schfs_dir_iterate(&VOL, &c, ino, tree_entry, (void *)path);
chain_pop();
return r;
}
/* ------------------------------------------------------------------ */
/* --extract */
/* ------------------------------------------------------------------ */
// Objects already written out, so a hard link on the image becomes a hard link
// on the host rather than a second copy. Without this the round-trip test's
// link-count check could not distinguish dedup from duplication.
struct seen_ent { uint64_t ino; char *path; };
static struct seen_ent *seen;
static int seen_n, seen_cap;
static const char *
seen_lookup(uint64_t ino)
{
for (int i = 0; i < seen_n; i++)
if (seen[i].ino == ino)
return seen[i].path;
return NULL;
}
static void
seen_add(uint64_t ino, const char *path)
{
if (seen_n == seen_cap) {
seen_cap = seen_cap ? seen_cap * 2 : 128;
seen = realloc(seen, (size_t)seen_cap * sizeof(*seen));
if (!seen) { perror("realloc"); exit(1); }
}
seen[seen_n].ino = ino;
seen[seen_n].path = strdup(path);
seen_n++;
}
static long xfiles, xdirs, xlinks, xsymlinks;
static int extract_dir(uint64_t ino, const char *hostpath);
static int
extract_entry(uint64_t ino, uint16_t type, const char *name, unsigned namelen,
void *arg)
{
const char *parent = arg;
char host[8192];
(void)type; /* the inode is authoritative, not the dirent */
snprintf(host, sizeof(host), "%s/%.*s", parent, (int)namelen, name);
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
struct schfs_inode in;
if (schfs_inode_get(&VOL, &c, ino, &in) != 0) {
fprintf(stderr, "schfs_fsck: %s: inode %llu unreadable\n",
host, (unsigned long long)ino);
return -1;
}
if (in.type == SCHFS_T_DIR) {
if (mkdir(host, in.mode & 07777) != 0 && errno != EEXIST) {
perror(host);
return -1;
}
xdirs++;
return extract_dir(ino, host);
}
if (in.type == SCHFS_T_SYMLINK) {
char target[4096];
if (in.size >= sizeof(target)) {
fprintf(stderr, "schfs_fsck: %s: symlink target too long\n", host);
return -1;
}
long got = schfs_file_read(&VOL, &c, ino, 0, target,
(unsigned)in.size, DATABLK);
if (got < 0 || (uint64_t)got != in.size) {
fprintf(stderr, "schfs_fsck: %s: could not read symlink target\n", host);
return -1;
}
target[in.size] = '\0';
unlink(host);
if (symlink(target, host) != 0) { perror(host); return -1; }
xsymlinks++;
return 0;
}
if (in.type == SCHFS_T_DEV) {
// Creating a real device node needs privileges the build does not have,
// so record it as an empty placeholder. The round-trip tree contains no
// device nodes; --check is what validates them.
FILE *f = fopen(host, "wb");
if (!f) { perror(host); return -1; }
fclose(f);
return 0;
}
// A second name for an object already written: make it a real hard link so
// the extracted tree has the same link structure as the image.
const char *first = seen_lookup(ino);
if (first) {
unlink(host);
if (link(first, host) != 0) { perror(host); return -1; }
xlinks++;
return 0;
}
FILE *f = fopen(host, "wb");
if (!f) { perror(host); return -1; }
uint64_t off = 0;
while (off < in.size) {
// A full block is requested every time, even at the tail. Clamping
// here instead would mean nothing ever tests that schfs_file_read
// stops at end-of-file -- and the kernel's read(2) will depend on
// exactly that, since a real program just asks for a bufferful.
char buf[SCHFS_BLKSIZE];
unsigned want = sizeof(buf);
long got = schfs_file_read(&VOL, &c, ino, off, buf, want, DATABLK);
if (got <= 0) {
fprintf(stderr, "schfs_fsck: %s: read error at %llu\n",
host, (unsigned long long)off);
fclose(f);
return -1;
}
fwrite(buf, 1, (size_t)got, f);
off += (uint64_t)got;
}
fclose(f);
if (chmod(host, in.mode & 07777) != 0)
perror(host);
seen_add(ino, host);
xfiles++;
return 0;
}
static int
extract_dir(uint64_t ino, const char *hostpath)
{
if (chain_push(ino, hostpath) != 0)
return -1;
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
int r = schfs_dir_iterate(&VOL, &c, ino, extract_entry, (void *)hostpath);
chain_pop();
return r;
}
static int
do_extract(const char *dir)
{
if (mkdir(dir, 0755) != 0 && errno != EEXIST) {
perror(dir);
return 1;
}
if (extract_dir(VOL.root_objectid, dir) != 0)
return 1;
printf("extracted %ld dirs, %ld files, %ld hard links, %ld symlinks into %s\n",
xdirs, xfiles, xlinks, xsymlinks, dir);
return 0;
}
/* ------------------------------------------------------------------ */
int
main(int argc, char **argv)
{
if (argc < 3) {
fprintf(stderr,
"usage: schfs_fsck img.schfs <action>\n"
" --info uberblock ring and volume summary\n"
" --check full structural and data verification\n"
" --ls PATH list a directory\n"
" --stat PATH one object's metadata\n"
" --cat PATH a file's contents\n"
" --tree every path in the volume\n"
" --extract DIR write the whole tree onto the host\n");
return 2;
}
const char *imgpath = argv[1];
const char *action = argv[2];
const char *arg = argc > 3 ? argv[3] : NULL;
// Must match whatever mkschfs used, or every directory lookup misses.
for (int i = 3; i + 1 < argc; i++)
if (strcmp(argv[i], "--hash-bits") == 0) {
int bits = atoi(argv[i + 1]);
schfs_hash_mask = bits >= 64 ? ~0ULL : ((1ULL << bits) - 1);
if (arg == argv[i])
arg = NULL;
}
FILE *f = fopen(imgpath, "rb");
if (!f) { perror(imgpath); return 1; }
if (fseeko(f, 0, SEEK_END) != 0) { perror(imgpath); return 1; }
off_t sz = ftello(f);
IMG.f = f;
IMG.nblocks = (uint64_t)sz / SCHFS_BLKSIZE;
struct schfs_bdev bd;
bd.ctx = &IMG;
bd.bread = img_read;
bd.bwrite = img_write;
bd.balloc = img_alloc;
bd.bfree = img_nofree;
bd.balloc_run = NULL; /* read-only: nothing is ever allocated */
// No page allocator: this tool never writes, and a volume without an
// allocation map cannot allocate even by accident.
int r = schfs_vol_open(&VOL, &bd, &CTX, NULL);
if (r == -1) {
fprintf(stderr, "schfs_fsck: %s: no valid uberblock -- not a SchFS "
"volume, or never successfully written\n", imgpath);
return 1;
}
if (r != 0) {
fprintf(stderr, "schfs_fsck: %s: newest uberblock names an unreadable "
"tree root\n", imgpath);
return 1;
}
if (strcmp(action, "--info") == 0)
return do_info();
if (strcmp(action, "--check") == 0)
return do_check();
if (strcmp(action, "--tree") == 0)
return tree_walk(VOL.root_objectid, "");
if (strcmp(action, "--ls") == 0 && arg)
return do_ls(arg);
if (strcmp(action, "--stat") == 0 && arg)
return do_stat(arg);
if (strcmp(action, "--cat") == 0 && arg)
return do_cat(arg);
if (strcmp(action, "--extract") == 0 && arg)
return do_extract(arg);
fprintf(stderr, "schfs_fsck: unknown or incomplete action '%s'\n", action);
return 2;
}

568
tools/schfs_objtest.c Normal file
View file

@ -0,0 +1,568 @@
// SchFS object-layer test: files, directories and space accounting.
//
// schfs_test covers the B-tree. This covers everything built on it — create,
// write at an offset, truncate, unlink, rename, hard links — and, most
// importantly, that space comes back. A filesystem that loses a block on every
// delete passes every functional test ever written and is still useless.
//
// Everything runs against a memory-backed volume, so a full create/write/
// delete/remount cycle takes milliseconds. The kernel gets this code only once
// it passes here, because the same failure inside a VM costs a rebuild and a
// boot to see once.
//
// Build and run: make schfs-objtest
#define SCHFS_HOSTED 1
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "../kernel/fs/schfs/btree.c"
#include "../kernel/fs/schfs/space.c"
#include "../kernel/fs/schfs/vol.c"
#include "../kernel/fs/schfs/obj.c"
#include "../kernel/fs/schfs/objw.c"
/* ------------------------------------------------------------------ */
/* Memory-backed device */
/* ------------------------------------------------------------------ */
#define NBLOCKS 4096
static unsigned char *DISK;
static struct schfs_vol VOL;
static char CTX_A[SCHFS_BLKSIZE];
static char CTX_B[SCHFS_BLKSIZE];
static char CTX_C[SCHFS_BLKSIZE];
static struct schfs_ctx CTX = { CTX_A, CTX_B, CTX_C };
static char CURBLK[SCHFS_BLKSIZE];
static char DATABLK[SCHFS_BLKSIZE];
static int
md_read(void *ctx, uint64_t b, void *buf)
{
(void)ctx;
if (b >= NBLOCKS) return -1;
memcpy(buf, DISK + b * SCHFS_BLKSIZE, SCHFS_BLKSIZE);
return 0;
}
static int
md_write(void *ctx, uint64_t b, const void *buf)
{
(void)ctx;
if (b >= NBLOCKS) return -1;
memcpy(DISK + b * SCHFS_BLKSIZE, buf, SCHFS_BLKSIZE);
return 0;
}
static uint64_t md_alloc(void *ctx) { (void)ctx; return schfs_vol_alloc(&VOL); }
static uint64_t md_run(void *ctx, uint64_t n) { (void)ctx; return schfs_vol_alloc_run(&VOL, n); }
static void md_free(void *ctx, uint64_t b) { (void)ctx; schfs_vol_free(&VOL, b); }
static void *md_palloc(void) { return calloc(1, SCHFS_BLKSIZE); }
static void
mkbdev(struct schfs_bdev *bd)
{
bd->ctx = 0;
bd->bread = md_read;
bd->bwrite = md_write;
bd->balloc = md_alloc;
bd->bfree = md_free;
bd->balloc_run = md_run;
}
/* ------------------------------------------------------------------ */
static int failures;
static void
ok(int cond, const char *what)
{
if (cond) {
printf(" [ok] %s\n", what);
} else {
printf(" FAIL: %s\n", what);
failures++;
}
}
static uint32_t rng = 987654321;
static uint32_t
rnd(void)
{
rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5;
return rng;
}
// Read a whole file into buf, returning its length or -1.
static long
readall(uint64_t ino, char *buf, unsigned cap)
{
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
struct schfs_inode in;
if (schfs_inode_get(&VOL, &c, ino, &in) != 0)
return -1;
if (in.size > cap)
return -1;
unsigned off = 0;
while (off < in.size) {
long got = schfs_file_read(&VOL, &c, ino, off, buf + off,
(unsigned)(in.size - off), DATABLK);
if (got <= 0)
return -1;
off += (unsigned)got;
}
return (long)off;
}
static uint64_t
lookup(const char *path)
{
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
return schfs_path_lookup(&VOL, &c, path);
}
static uint32_t
nlink_of(uint64_t ino)
{
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
struct schfs_inode in;
if (schfs_inode_get(&VOL, &c, ino, &in) != 0)
return 0;
return in.nlink;
}
static uint64_t
size_of(uint64_t ino)
{
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
struct schfs_inode in;
if (schfs_inode_get(&VOL, &c, ino, &in) != 0)
return ~0ULL;
return in.size;
}
static int
count_extents_of(uint64_t ino)
{
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
struct schfs_key start;
schfs_mkkey(&start, ino, SCHFS_KEY_EXTENT, 0);
if (schfs_cursor_seek(&c, &start) != 0)
return 0;
int n = 0;
for (;;) {
const struct schfs_key *k = schfs_cursor_key(&c);
if (!k || k->objectid != ino || k->type != SCHFS_KEY_EXTENT)
break;
n++;
if (schfs_cursor_next(&c) != 0)
break;
}
return n;
}
// Publish, then reopen the volume from its uberblock — which rebuilds the
// allocation map by walking the tree. Returns the free count the writer had, so
// a caller can hold it against the one the mount derived: a bitmap that has
// drifted from its own counter shows up here and nowhere else.
//
// Reopening also ages every block into a previous transaction group, which is
// what makes the copy-on-write paths run at all. Within one group the tree and
// the data both rewrite in place, so a test that never commits exercises the
// cheap half of the filesystem and none of the careful half.
static uint64_t
remount(void)
{
if (schfs_vol_commit(&VOL) != 0) {
printf(" FAIL: commit before remount\n");
failures++;
return ~0ULL;
}
uint64_t before = VOL.free_blocks;
struct schfs_bdev bd2;
mkbdev(&bd2);
// Straight back into VOL, not into a local that is then copied over it: a
// schfs_vol points at its own dev field, so a copy of one leaves the tree
// talking to whichever struct it was built in.
memset(&VOL, 0, sizeof(VOL));
if (schfs_vol_open(&VOL, &bd2, &CTX, md_palloc) != 0) {
printf(" FAIL: remount\n");
failures++;
return ~0ULL;
}
return before;
}
/* ------------------------------------------------------------------ */
#define ROOT (VOL.root_objectid)
static uint64_t
mkfile(uint64_t dir, const char *name)
{
uint64_t ino = 0;
int r = schfs_create(&VOL, dir, name, (unsigned)strlen(name),
SCHFS_T_FILE, 0644, 0, 0, 1000, &ino);
if (r != 0) {
printf(" FAIL: create %s -> %d\n", name, r);
failures++;
return 0;
}
return ino;
}
static uint64_t
mkdirent(uint64_t dir, const char *name)
{
uint64_t ino = 0;
int r = schfs_create(&VOL, dir, name, (unsigned)strlen(name),
SCHFS_T_DIR, 0755, 0, 0, 1000, &ino);
if (r != 0) {
printf(" FAIL: mkdir %s -> %d\n", name, r);
failures++;
return 0;
}
return ino;
}
int
main(void)
{
struct schfs_bdev bd;
DISK = calloc(NBLOCKS, SCHFS_BLKSIZE);
if (!DISK) { perror("calloc"); return 1; }
mkbdev(&bd);
printf("SchFS object-layer test\n\n");
if (schfs_vol_format(&VOL, &bd, NBLOCKS, "objtest", &CTX, md_palloc) != 0) {
fprintf(stderr, "FAIL: format\n");
return 1;
}
// The root directory is an object like any other; nothing points at it
// except the uberblock's root_objectid.
{
struct schfs_inode root;
memset(&root, 0, sizeof(root));
root.type = SCHFS_T_DIR;
root.mode = 0755;
root.nlink = 2;
if (schfs_inode_put(&VOL, ROOT, &root) != 0) {
fprintf(stderr, "FAIL: root inode\n");
return 1;
}
}
if (schfs_vol_commit(&VOL) != 0) { fprintf(stderr, "FAIL: commit\n"); return 1; }
uint64_t baseline_free = VOL.free_blocks;
/* ---- 1. create, write, read back ---- */
{
uint64_t d = mkdirent(ROOT, "dir");
uint64_t f = mkfile(d, "hello");
const char *msg = "the quick brown fox";
long w = schfs_file_write(&VOL, f, 0, msg, (unsigned)strlen(msg), DATABLK);
ok(w == (long)strlen(msg), "write returns the byte count");
char buf[128];
long got = readall(f, buf, sizeof(buf));
ok(got == (long)strlen(msg) && memcmp(buf, msg, (size_t)got) == 0,
"a short file reads back exactly");
ok(lookup("/dir/hello") == f, "path lookup finds it");
ok(nlink_of(d) == 2, "a new directory has nlink 2");
ok(nlink_of(ROOT) == 3, "creating a subdirectory bumps the parent");
}
/* ---- 2. a large file becomes few extents, not one per block ---- */
{
uint64_t f = mkfile(ROOT, "big");
static char src[64 * 1024];
for (unsigned i = 0; i < sizeof(src); i++)
src[i] = (char)(i * 7 + (i >> 8));
// Written in small pieces on purpose: this is the shape that used to
// produce one extent record per block, and one copy-on-write per call.
unsigned off = 0;
while (off < sizeof(src)) {
unsigned n = 100 + rnd() % 900;
if (off + n > sizeof(src)) n = (unsigned)sizeof(src) - off;
long w = schfs_file_write(&VOL, f, off, src + off, n, DATABLK);
if (w != (long)n) { printf(" FAIL: partial write %ld\n", w); failures++; break; }
off += n;
}
static char back[64 * 1024];
long got = readall(f, back, sizeof(back));
ok(got == (long)sizeof(src) && memcmp(back, src, sizeof(src)) == 0,
"64 KB written in small pieces reads back identical");
int ne = count_extents_of(f);
ok(ne <= 4, "sequential writes merge into a handful of extents");
if (ne > 4)
printf(" (%d extents for 16 blocks)\n", ne);
}
/* ---- 3. overwriting the middle, and holes ---- */
{
uint64_t f = mkfile(ROOT, "patch");
static char src[16 * 1024];
memset(src, 'a', sizeof(src));
schfs_file_write(&VOL, f, 0, src, sizeof(src), DATABLK);
// Straddles a block boundary, so it exercises the split path on both
// sides of the extent it lands in.
char patch[600];
memset(patch, 'Z', sizeof(patch));
schfs_file_write(&VOL, f, 4000, patch, sizeof(patch), DATABLK);
memcpy(src + 4000, patch, sizeof(patch));
static char back[16 * 1024];
long got = readall(f, back, sizeof(back));
ok(got == (long)sizeof(src) && memcmp(back, src, sizeof(src)) == 0,
"an overwrite across a block boundary lands exactly");
// Now the same thing across a commit. Everything above happened inside
// one transaction group, where a data block may be rewritten in place;
// once it is committed it belongs to a tree a crash can still land on,
// so an overwrite has to copy the block and split the extent record
// around it. That is the path that actually risks losing data, and only
// a commit makes it run.
if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; }
char patch2[3000];
memset(patch2, 'K', sizeof(patch2));
long w = schfs_file_write(&VOL, f, 9000, patch2, sizeof(patch2), DATABLK);
ok(w == (long)sizeof(patch2), "an overwrite after a commit succeeds");
memcpy(src + 9000, patch2, sizeof(patch2));
got = readall(f, back, sizeof(back));
ok(got == (long)sizeof(src) && memcmp(back, src, sizeof(src)) == 0,
"copy-on-write of a committed block keeps the rest of the run intact");
// And the block it replaced must really be gone, not still in the map.
uint64_t was = remount();
ok(was == VOL.free_blocks,
"free space after a copy-on-write overwrite survives a remount");
got = readall(lookup("/patch"), back, sizeof(back));
ok(got == (long)sizeof(src) && memcmp(back, src, sizeof(src)) == 0,
"and the patched file still reads correctly after remounting");
uint64_t h = mkfile(ROOT, "sparse");
schfs_file_write(&VOL, h, 40000, "end", 3, DATABLK);
ok(size_of(h) == 40003, "writing past the end extends the file");
static char hb[64 * 1024];
got = readall(h, hb, sizeof(hb));
int zeros = (got == 40003);
for (long i = 0; i < 40000 && zeros; i++)
if (hb[i] != 0) zeros = 0;
ok(zeros && memcmp(hb + 40000, "end", 3) == 0,
"the gap left behind is a hole and reads as zeros");
}
/* ---- 4. truncate ---- */
{
uint64_t f = mkfile(ROOT, "trunc");
static char src[20 * 1024];
memset(src, 'q', sizeof(src));
schfs_file_write(&VOL, f, 0, src, sizeof(src), DATABLK);
ok(schfs_file_truncate(&VOL, f, 100, DATABLK) == 0, "truncate succeeds");
ok(size_of(f) == 100, "truncate sets the size");
char back[256];
long got = readall(f, back, sizeof(back));
int good = (got == 100);
for (int i = 0; i < 100 && good; i++)
if (back[i] != 'q') good = 0;
ok(good, "what survives a truncate is the original head");
// Growing again must read as zeros, not as whatever the released
// blocks used to hold.
ok(schfs_file_truncate(&VOL, f, 8192, DATABLK) == 0, "truncate can grow");
static char big[8192];
got = readall(f, big, sizeof(big));
int zeroed = (got == 8192);
for (int i = 100; i < 8192 && zeroed; i++)
if (big[i] != 0) zeroed = 0;
ok(zeroed, "growing past the old end reads as zeros, not stale data");
}
/* ---- 5. links, unlink, rename, rmdir ---- */
{
uint64_t f = mkfile(ROOT, "orig");
schfs_file_write(&VOL, f, 0, "content", 7, DATABLK);
ok(schfs_link(&VOL, ROOT, "alias", 5, f) == 0, "hard link succeeds");
ok(nlink_of(f) == 2, "hard link bumps nlink");
ok(lookup("/alias") == f, "both names reach the same object");
ok(schfs_unlink(&VOL, ROOT, "orig", 4) == 0, "unlink one name");
ok(nlink_of(f) == 1, "nlink drops but the object survives");
char buf[32];
ok(readall(f, buf, sizeof(buf)) == 7, "the surviving name still reads");
ok(schfs_rename(&VOL, ROOT, "alias", 5, ROOT, "renamed", 7) == 0,
"rename within a directory");
ok(lookup("/alias") == 0 && lookup("/renamed") == f,
"the old name is gone and the new one resolves");
uint64_t d = lookup("/dir");
ok(schfs_unlink(&VOL, ROOT, "dir", 3) == -5,
"rmdir refuses a directory that still has entries");
ok(schfs_unlink(&VOL, d, "hello", 5) == 0, "emptying it first");
ok(schfs_unlink(&VOL, ROOT, "dir", 3) == 0, "then rmdir succeeds");
ok(nlink_of(ROOT) == 2, "removing a subdirectory drops the parent's count");
// Moving a directory under itself would detach the whole subtree.
uint64_t a = mkdirent(ROOT, "a");
uint64_t b = mkdirent(a, "b");
(void)b;
ok(schfs_rename(&VOL, ROOT, "a", 1, lookup("/a/b"), "loop", 4) != 0,
"renaming a directory into its own subtree is refused");
}
/* ---- 6. it all survives a commit and a remount ---- */
{
if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; }
static char back_before[64 * 1024];
long before = readall(lookup("/big"), back_before, sizeof(back_before));
uint64_t was_free = remount();
ok(was_free != ~0ULL, "the volume reopens from its uberblock");
static char back_after[64 * 1024];
long after = readall(lookup("/big"), back_after, sizeof(back_after));
ok(before == after && before > 0 &&
memcmp(back_before, back_after, (size_t)before) == 0,
"file contents are identical after a remount");
ok(lookup("/renamed") != 0 && lookup("/dir") == 0,
"the namespace is exactly as it was left");
ok(VOL.free_blocks == was_free,
"free space derived at mount matches what the writer had");
if (VOL.free_blocks != was_free)
printf(" (mount says %llu, writer said %llu)\n",
(unsigned long long)VOL.free_blocks,
(unsigned long long)was_free);
}
/* ---- 7. deleting everything gives the space back ----
*
* The one test that catches a leak. Every earlier check would still pass on
* a filesystem that never reused a block; this one does not.
*/
{
const char *names[] = { "big", "patch", "sparse", "trunc", "renamed" };
for (unsigned i = 0; i < sizeof(names) / sizeof(names[0]); i++) {
int r = schfs_unlink(&VOL, ROOT, names[i], (unsigned)strlen(names[i]));
if (r != 0) { printf(" FAIL: unlink %s -> %d\n", names[i], r); failures++; }
}
int r = schfs_unlink(&VOL, lookup("/a"), "b", 1);
if (r == 0) r = schfs_unlink(&VOL, ROOT, "a", 1);
if (r != 0) { printf(" FAIL: rmdir a/b -> %d\n", r); failures++; }
if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; }
ok(VOL.free_blocks == baseline_free,
"an emptied filesystem is back to its starting free-space count");
if (VOL.free_blocks != baseline_free)
printf(" (%llu free now, %llu when empty: %lld blocks lost)\n",
(unsigned long long)VOL.free_blocks,
(unsigned long long)baseline_free,
(long long)baseline_free - (long long)VOL.free_blocks);
ok(schfs_btree_check(&VOL.tree) == 0,
"the tree is still structurally sound after all of that");
}
/* ---- 8. fill, empty, repeat: the space has to be genuinely reusable ----
*
* The free *count* coming back is not the same as the free *map* coming
* back, and a remount hides the difference -- it throws the map away and
* derives a correct one from the tree. Only spending the space again, on
* the same mount, holds the two against each other. A map that leaks a
* little on every delete stores less on each round until nothing fits.
*/
{
static char chunk[32 * 1024];
memset(chunk, 'x', sizeof(chunk));
uint64_t first_round = 0;
int good = 1;
for (int round = 0; round < 3 && good; round++) {
uint64_t f = mkfile(ROOT, "hog");
uint64_t off = 0;
int saw_enospc = 0;
for (int i = 0; i < 10000; i++) {
long w = schfs_file_write(&VOL, f, off, chunk,
sizeof(chunk), DATABLK);
if (w == -3) { saw_enospc = 1; break; }
if (w != (long)sizeof(chunk)) {
printf(" FAIL: write returned %ld\n", w);
good = 0;
break;
}
off += sizeof(chunk);
}
if (round == 0) {
ok(saw_enospc,
"filling the volume reports out of space rather than corrupting");
first_round = off;
} else if (off + 128 * 1024 < first_round) {
printf(" FAIL: round %d stored %llu KB, round 0 stored %llu KB\n",
round, (unsigned long long)(off / 1024),
(unsigned long long)(first_round / 1024));
good = 0;
}
int r = schfs_unlink(&VOL, ROOT, "hog", 3);
if (r != 0) {
printf(" FAIL: could not delete on a full volume (%d)\n", r);
good = 0;
}
if (schfs_vol_commit(&VOL) != 0) {
printf(" FAIL: commit\n");
good = 0;
}
}
ok(good, "filling and emptying three times reuses the same space");
ok(schfs_btree_check(&VOL.tree) == 0,
"the tree survives being filled to the brim");
}
/* ---- 9. the map and its counter still agree ---- */
{
uint64_t claimed = remount();
ok(claimed == VOL.free_blocks,
"free space counted in memory matches free space derived from the tree");
if (claimed != VOL.free_blocks)
printf(" (counter said %llu, the tree says %llu)\n",
(unsigned long long)claimed,
(unsigned long long)VOL.free_blocks);
}
printf("\n%s\n", failures ? "FAILED" : "all checks passed");
return failures ? 1 : 0;
}

120
tools/schfs_roundtrip.sh Normal file
View file

@ -0,0 +1,120 @@
#!/bin/sh
# SPDX-License-Identifier: Apache-2.0
#
# SchFS round-trip test: build an image from a directory tree, read it back
# with a completely separate program, and diff the result against the original.
#
# The point is the oracle. schfs_test checks the B-tree against a model written
# for the purpose; this checks the *filesystem* against a tree produced by
# something with no connection to SchFS at all -- the host's own filesystem.
# If mkschfs and schfs_fsck ever agree on something wrong, the source tree still
# disagrees, and diff says so.
#
# tools/schfs_roundtrip.sh <mkschfs> <schfs_fsck> [tree]
#
# With no tree, a synthetic one is generated covering the cases that have
# historically broken image builders: empty files, empty directories, files
# spanning several blocks, files ending exactly on a block boundary, duplicate
# content that must be hard-linked, symlinks, deep nesting and awkward names.
set -e
# Every tool here is piped into sed for indenting, and a shell only checks the
# *last* command of a pipeline. Without this, schfs_fsck could exit non-zero --
# or crash outright -- and the script would sail past it, which is exactly what
# happened the first time this ran.
if (set -o pipefail) 2>/dev/null; then set -o pipefail; fi
MKSCHFS=${1:?usage: schfs_roundtrip.sh <mkschfs> <schfs_fsck> [tree]}
FSCK=${2:?usage: schfs_roundtrip.sh <mkschfs> <schfs_fsck> [tree]}
TREE=$3
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
if [ -z "$TREE" ]; then
# Generated by a separate script so the same tree can be rebuilt by hand
# when debugging a failure, without running the whole round-trip.
TREE="$WORK/tree"
sh "$(dirname "$0")/mktree.sh" "$TREE"
fi
IMG="$WORK/out.img"
OUT="$WORK/extracted"
echo "SchFS round-trip"
echo " source tree: $TREE"
"$MKSCHFS" "$IMG" --size 4096 --label roundtrip --sysroot "$TREE" | sed 's/^/ /'
echo
"$FSCK" "$IMG" --check | sed 's/^/ /'
mkdir -p "$OUT"
"$FSCK" "$IMG" --extract "$OUT" | sed 's/^/ /'
echo
# --no-dereference so a symlink is compared as a symlink rather than followed;
# without it a broken link that was stored correctly still fails the diff.
if diff -r --no-dereference "$TREE" "$OUT"; then
echo " [ok] extracted tree is identical to the source"
else
echo " FAIL: extracted tree differs from the source"
exit 1
fi
# Hard links are the one thing diff cannot see: it compares contents, and two
# separate copies with identical contents pass. Check the link count directly,
# or dedup could silently degrade to duplication and nothing would notice.
LINKS=$("$FSCK" "$IMG" --stat /bin/multiblock | sed -n 's/^nlink: *//p')
if [ "$LINKS" != "2" ]; then
echo " FAIL: /bin/multiblock has nlink=$LINKS, expected 2 (dedup did not hard-link)"
exit 1
fi
echo " [ok] duplicate content stored once, with nlink=2"
# ...and the converse: same size, different bytes must stay separate.
DLINKS=$("$FSCK" "$IMG" --stat /bin/decoy | sed -n 's/^nlink: *//p')
if [ "$DLINKS" != "1" ]; then
echo " FAIL: /bin/decoy has nlink=$DLINKS, expected 1 (dedup merged unequal files)"
exit 1
fi
echo " [ok] same-size different-content files kept separate"
# Building the same tree twice must produce the same bytes. A difference means
# something non-deterministic crept in, which makes every future image diff
# useless as a signal.
IMG2="$WORK/out2.img"
"$MKSCHFS" "$IMG2" --size 4096 --label roundtrip --sysroot "$TREE" > /dev/null
if cmp -s "$IMG" "$IMG2"; then
echo " [ok] rebuilding the same tree produces an identical image"
else
echo " FAIL: image build is not reproducible"
exit 1
fi
# Second pass with a deliberately crippled name hash.
#
# With the real 64-bit hash, two names in one directory never collide, so the
# packed-dirent list -- the fiddliest code in the object layer -- never runs at
# all. Six bits means 64 buckets for ~330 names, so every bucket holds several
# entries and lookup, readdir and the append-on-collision path all get properly
# exercised. Both tools must agree on the mask or nothing resolves.
echo
echo " forcing hash collisions (6-bit name hash)"
IMG3="$WORK/collide.img"
OUT3="$WORK/collide-out"
"$MKSCHFS" "$IMG3" --size 4096 --hash-bits 6 --sysroot "$TREE" | sed 's/^/ /'
"$FSCK" "$IMG3" --check --hash-bits 6 | sed 's/^/ /'
mkdir -p "$OUT3"
"$FSCK" "$IMG3" --extract "$OUT3" --hash-bits 6 | sed 's/^/ /'
if diff -r --no-dereference "$TREE" "$OUT3" > /dev/null; then
echo " [ok] identical tree survives a directory hash that collides constantly"
else
echo " FAIL: colliding-hash image does not round-trip"
diff -r --no-dereference "$TREE" "$OUT3" | head -10
exit 1
fi
echo
echo "round-trip passed"

617
tools/schfs_test.c Normal file
View file

@ -0,0 +1,617 @@
// SchFS B-tree torture harness.
//
// The B-tree is the one component of SchFS where a subtle bug is unfixable
// later: every other record type is built on it, so a corruption here is a
// corruption in everything. Testing it inside the kernel means rebuilding an
// image and driving a shell for each attempt. Testing it here means a fast
// loop against a reference model, so this runs first and the kernel gets the
// code only once it passes.
//
// What it checks:
// 1. Against a reference model — a plain sorted array — after every batch of
// random inserts, replaces and deletes: same keys, same values, in order.
// 2. That the tree is structurally sound (magic, self-pointer, checksum,
// level, key ordering) after every batch.
// 3. Crash safety: writes made after a commit are thrown away and the tree
// is re-opened from the last committed root. Copy-on-write means it must
// come back exactly as it was at that commit, never a mixture.
// 4. That a deliberately corrupted block is *detected* rather than silently
// returning bad data.
//
// Build and run: make schfs-test
#define SCHFS_HOSTED 1
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
#include "../kernel/fs/schfs/btree.c"
/* ------------------------------------------------------------------ */
/* Memory-backed block device with commit/rollback */
/* ------------------------------------------------------------------ */
#define NBLOCKS 8192
struct memdev {
unsigned char blk[NBLOCKS][SCHFS_BLKSIZE];
unsigned char committed[NBLOCKS][SCHFS_BLKSIZE];
int allocated[NBLOCKS]; // live in the working tree
int committed_alloc[NBLOCKS];
uint64_t next;
long writes;
int fail_after; // simulate a crash: -1 = never
};
static int
md_read(void *ctx, uint64_t b, void *buf)
{
struct memdev *d = ctx;
if (b == 0 || b >= NBLOCKS) return -1;
memcpy(buf, d->blk[b], SCHFS_BLKSIZE);
return 0;
}
static int
md_write(void *ctx, uint64_t b, const void *buf)
{
struct memdev *d = ctx;
if (b == 0 || b >= NBLOCKS) return -1;
if (d->fail_after >= 0 && d->writes >= d->fail_after)
return -1; // the "crash"
memcpy(d->blk[b], buf, SCHFS_BLKSIZE);
d->writes++;
return 0;
}
static uint64_t
md_alloc(void *ctx)
{
struct memdev *d = ctx;
for (uint64_t i = d->next; i < NBLOCKS; i++) {
// Never hand back a block the committed tree still references --
// that is the core copy-on-write safety rule.
if (!d->allocated[i] && !d->committed_alloc[i]) {
d->allocated[i] = 1;
d->next = i + 1;
return i;
}
}
for (uint64_t i = SCHFS_UBER_START + 1; i < d->next; i++) {
if (!d->allocated[i] && !d->committed_alloc[i]) {
d->allocated[i] = 1;
return i;
}
}
return 0; // out of space
}
static void
md_free(void *ctx, uint64_t b)
{
struct memdev *d = ctx;
if (b > 0 && b < NBLOCKS)
d->allocated[b] = 0; // reusable only once committed
}
// Publish everything written so far and open a new transaction group.
//
// Advancing the tree's generation is not bookkeeping — it is the rule the tree
// uses to decide whether a node may be rewritten in place. Without it every
// block would look like part of the open transaction and the tree would happily
// overwrite the very blocks this snapshot is supposed to preserve, so the
// rollback test below is what keeps that rule honest.
static void
md_commit(struct memdev *d, struct schfs_btree *t)
{
memcpy(d->committed, d->blk, sizeof(d->blk));
memcpy(d->committed_alloc, d->allocated, sizeof(d->allocated));
t->generation++;
}
static void
md_rollback(struct memdev *d)
{
memcpy(d->blk, d->committed, sizeof(d->blk));
memcpy(d->allocated, d->committed_alloc, sizeof(d->allocated));
}
/* ------------------------------------------------------------------ */
/* Reference model */
/* ------------------------------------------------------------------ */
#define MAXREF 4000
struct ref_ent {
struct schfs_key key;
unsigned char val[64];
unsigned len;
int live;
};
static struct ref_ent ref[MAXREF];
static int nref;
// Deliberately does NOT call schfs_key_cmp. The reference model is the oracle,
// so it must not share the code under test: when the model used the real
// comparator, a deliberate bug in schfs_key_cmp broke the tree and the oracle
// in exactly the same way and every check still passed. Written out longhand
// so a bug in the real comparator surfaces as a disagreement.
static int
ref_key_cmp(const struct schfs_key *a, const struct schfs_key *b)
{
if (a->objectid != b->objectid) return a->objectid < b->objectid ? -1 : 1;
if (a->type != b->type) return a->type < b->type ? -1 : 1;
if (a->offset != b->offset) return a->offset < b->offset ? -1 : 1;
return 0;
}
static int
ref_find(const struct schfs_key *k)
{
for (int i = 0; i < nref; i++)
if (ref[i].live && ref_key_cmp(&ref[i].key, k) == 0)
return i;
return -1;
}
static int
ref_cmp(const void *a, const void *b)
{
return ref_key_cmp(&((const struct ref_ent *)a)->key,
&((const struct ref_ent *)b)->key);
}
/* Compare a full tree walk against the reference model. */
struct cmp_ctx {
struct ref_ent *sorted;
int n;
int i;
int bad;
};
static int
cmp_one(const struct schfs_key *k, const void *v, unsigned len, void *arg)
{
struct cmp_ctx *c = arg;
if (c->i >= c->n) {
fprintf(stderr, " tree has extra key (%llu,%u,%llu)\n",
(unsigned long long)k->objectid, k->type,
(unsigned long long)k->offset);
c->bad = 1;
return 1;
}
struct ref_ent *e = &c->sorted[c->i++];
if (ref_key_cmp(k, &e->key) != 0) {
fprintf(stderr, " key mismatch at %d: tree (%llu,%u,%llu) "
"vs ref (%llu,%u,%llu)\n", c->i - 1,
(unsigned long long)k->objectid, k->type,
(unsigned long long)k->offset,
(unsigned long long)e->key.objectid, e->key.type,
(unsigned long long)e->key.offset);
c->bad = 1;
return 1;
}
if (len != e->len || memcmp(v, e->val, len) != 0) {
fprintf(stderr, " value mismatch for key (%llu,%u,%llu)\n",
(unsigned long long)k->objectid, k->type,
(unsigned long long)k->offset);
c->bad = 1;
return 1;
}
return 0;
}
static int
verify(struct schfs_btree *t, const char *what)
{
static struct ref_ent sorted[MAXREF];
int n = 0;
for (int i = 0; i < nref; i++)
if (ref[i].live)
sorted[n++] = ref[i];
qsort(sorted, n, sizeof(sorted[0]), ref_cmp);
if (schfs_btree_check(t) != 0) {
fprintf(stderr, "FAIL [%s]: structural check failed\n", what);
return 1;
}
struct cmp_ctx c = { sorted, n, 0, 0 };
schfs_btree_iterate(t, cmp_one, &c);
if (c.bad) {
fprintf(stderr, "FAIL [%s]: contents differ from reference\n", what);
return 1;
}
if (c.i != n) {
fprintf(stderr, "FAIL [%s]: tree has %d records, reference has %d\n",
what, c.i, n);
return 1;
}
return 0;
}
/* ------------------------------------------------------------------ */
static uint32_t rng_state = 12345;
static uint32_t
rnd(void)
{
rng_state ^= rng_state << 13;
rng_state ^= rng_state >> 17;
rng_state ^= rng_state << 5;
return rng_state;
}
static struct memdev dev_storage;
// The three scratch blocks every mutation borrows. Statics, not locals, because
// this is exactly the shape the kernel has to use: a block is 4096 bytes and so
// is the whole kernel stack.
static char CTX_A[SCHFS_BLKSIZE];
static char CTX_B[SCHFS_BLKSIZE];
static char CTX_C[SCHFS_BLKSIZE];
static struct schfs_ctx CTX = { CTX_A, CTX_B, CTX_C };
static void
mkdev(struct schfs_bdev *bd, struct memdev *d)
{
memset(d, 0, sizeof(*d));
d->next = SCHFS_UBER_START + 1;
d->fail_after = -1;
bd->ctx = d;
bd->bread = md_read;
bd->bwrite = md_write;
bd->balloc = md_alloc;
bd->bfree = md_free;
bd->balloc_run = 0; /* the B-tree never asks for a run */
}
int
main(void)
{
struct schfs_bdev bd;
struct schfs_btree t;
int failures = 0;
printf("SchFS B-tree torture test\n");
printf(" block size %d, max inline value %d, fanout %d/%d (kptr/item)\n\n",
SCHFS_BLKSIZE, SCHFS_MAX_INLINE, SCHFS_MAX_KPTR, SCHFS_MAX_ITEMS);
/* ---- 1. random insert / replace / delete against the model ---- */
mkdev(&bd, &dev_storage);
memset(&t, 0, sizeof(t));
t.generation = 1; /* the first transaction group */
if (schfs_btree_create(&t, &bd, &CTX) != 0) {
fprintf(stderr, "FAIL: create\n");
return 1;
}
nref = 0;
// Committing part way through matters: between commits the tree rewrites
// its own nodes in place, and only after one does the copy-on-write path
// run at all. A test that never commits exercises half the code.
const int ROUNDS = 60, OPS = 60, COMMIT_EVERY = 20;
for (int round = 0; round < ROUNDS; round++) {
if (round && round % COMMIT_EVERY == 0)
md_commit(&dev_storage, &t);
for (int op = 0; op < OPS; op++) {
struct schfs_key k;
memset(&k, 0, sizeof(k));
k.objectid = rnd() % 200;
k.type = (uint8_t)(1 + rnd() % 3);
k.offset = rnd() % 50;
int action = rnd() % 10;
if (action < 7) { /* insert or replace */
unsigned char val[64];
unsigned len = 1 + rnd() % 48;
for (unsigned i = 0; i < len; i++)
val[i] = (unsigned char)(rnd() & 0xff);
int r = schfs_btree_insert(&t, &CTX, &k, val, len);
if (r == -3)
continue; /* out of blocks: fine */
if (r != 0) {
fprintf(stderr, "FAIL: insert returned %d\n", r);
return 1;
}
int idx = ref_find(&k);
if (idx < 0) {
if (nref >= MAXREF) continue;
idx = nref++;
ref[idx].key = k;
ref[idx].live = 1;
}
memcpy(ref[idx].val, val, len);
ref[idx].len = len;
} else { /* delete */
int r = schfs_btree_delete(&t, &CTX, &k);
int idx = ref_find(&k);
if (idx >= 0) {
if (r != 0) {
fprintf(stderr, "FAIL: delete of present key -> %d\n", r);
return 1;
}
ref[idx].live = 0;
} else if (r != -1) {
fprintf(stderr, "FAIL: delete of absent key -> %d "
"(expected -1)\n", r);
return 1;
}
}
}
if (verify(&t, "random ops")) {
failures++;
break;
}
}
if (!failures)
printf(" [ok] %d rounds x %d random ops match the reference model\n",
ROUNDS, OPS);
/* ---- 2. lookup agrees with the model for every live key ---- */
{
int checked = 0, bad = 0;
for (int i = 0; i < nref; i++) {
if (!ref[i].live) continue;
unsigned char buf[64];
unsigned len = sizeof(buf);
if (schfs_btree_lookup(&t, &CTX, &ref[i].key, buf, &len) != 0) {
bad++;
continue;
}
if (len != ref[i].len || memcmp(buf, ref[i].val, len) != 0)
bad++;
checked++;
}
if (bad) {
fprintf(stderr, "FAIL: %d of %d lookups wrong\n", bad, checked + bad);
failures++;
} else {
printf(" [ok] %d lookups all return the right value\n", checked);
}
}
/* ---- 3. the cursor: forward, backward, range and predecessor ----
*
* The cursor is what the kernel actually reads through, because it holds
* one caller-supplied block buffer instead of putting 4 KB on a 4 KB
* kernel stack per level. It re-reads a parent when climbing, so it has
* genuinely different failure modes from the recursive walker and is
* tested against the same independent model rather than against iterate.
*/
{
static struct ref_ent sorted[MAXREF];
int n = 0;
for (int i = 0; i < nref; i++)
if (ref[i].live)
sorted[n++] = ref[i];
qsort(sorted, n, sizeof(sorted[0]), ref_cmp);
static char curblk[SCHFS_BLKSIZE];
struct schfs_cursor c;
schfs_cursor_init(&c, &t, curblk);
/* (a) forward from the start */
int bad = 0, seen = 0;
if (schfs_cursor_seek(&c, NULL) != 0 && n > 0)
bad = 1;
while (!bad && seen < n) {
const struct schfs_key *k = schfs_cursor_key(&c);
unsigned vlen;
const void *v = schfs_cursor_val(&c, &vlen);
if (!k || !v) { bad = 1; break; }
if (ref_key_cmp(k, &sorted[seen].key) != 0 ||
vlen != sorted[seen].len ||
memcmp(v, sorted[seen].val, vlen) != 0) {
fprintf(stderr, " cursor diverges at record %d\n", seen);
bad = 1;
break;
}
seen++;
int r = schfs_cursor_next(&c);
if (r == -1) break;
if (r != 0) { bad = 1; break; }
}
if (bad || seen != n) {
fprintf(stderr, "FAIL: forward cursor walked %d of %d records\n",
seen, n);
failures++;
} else {
printf(" [ok] cursor walks all %d records forward in key order\n", n);
}
/* (b) backward from the end. Getting to the end uses lookup_le with a
* key past every real one, which also exercises the "greater than
* everything" branch. */
struct schfs_key maxk;
memset(&maxk, 0, sizeof(maxk));
maxk.objectid = ~0ULL; maxk.type = 255; maxk.offset = ~0ULL;
bad = 0; seen = 0;
if (n > 0 && schfs_btree_lookup_le(&c, &maxk) != 0)
bad = 1;
while (!bad && seen < n) {
const struct schfs_key *k = schfs_cursor_key(&c);
if (!k) { bad = 1; break; }
if (ref_key_cmp(k, &sorted[n - 1 - seen].key) != 0) {
fprintf(stderr, " reverse cursor diverges at record %d\n", seen);
bad = 1;
break;
}
seen++;
int r = schfs_cursor_prev(&c);
if (r == -1) break;
if (r != 0) { bad = 1; break; }
}
if (bad || seen != n) {
fprintf(stderr, "FAIL: reverse cursor walked %d of %d records\n",
seen, n);
failures++;
} else {
printf(" [ok] cursor walks all %d records backward\n", n);
}
/* (c) range scans from arbitrary start keys must yield exactly the
* model's suffix. This is the operation readdir depends on. */
bad = 0;
for (int trial = 0; trial < 200 && !bad; trial++) {
struct schfs_key start;
memset(&start, 0, sizeof(start));
start.objectid = rnd() % 220;
start.type = (uint8_t)(rnd() % 5);
start.offset = rnd() % 60;
int expect = 0;
while (expect < n && ref_key_cmp(&sorted[expect].key, &start) < 0)
expect++;
int r = schfs_cursor_seek(&c, &start);
if (r == -2) { bad = 1; break; }
if (expect == n) {
if (r == 0 && schfs_cursor_key(&c) != NULL) bad = 1;
continue;
}
if (r != 0) { bad = 1; break; }
const struct schfs_key *k = schfs_cursor_key(&c);
if (!k || ref_key_cmp(k, &sorted[expect].key) != 0) {
fprintf(stderr, " seek(%llu,%u,%llu) landed wrong\n",
(unsigned long long)start.objectid, start.type,
(unsigned long long)start.offset);
bad = 1;
}
}
if (bad) {
fprintf(stderr, "FAIL: range seek disagrees with the model\n");
failures++;
} else {
printf(" [ok] 200 range seeks land on the right record\n");
}
/* (d) lookup_le must find the greatest key <= the probe, which is how
* a file read finds the extent covering a byte offset. */
bad = 0;
for (int trial = 0; trial < 200 && !bad; trial++) {
struct schfs_key probe;
memset(&probe, 0, sizeof(probe));
probe.objectid = rnd() % 220;
probe.type = (uint8_t)(rnd() % 5);
probe.offset = rnd() % 60;
int expect = -1;
for (int i = 0; i < n; i++)
if (ref_key_cmp(&sorted[i].key, &probe) <= 0)
expect = i;
else
break;
int r = schfs_btree_lookup_le(&c, &probe);
if (expect < 0) {
if (r != -1) {
fprintf(stderr, " lookup_le below all keys returned %d\n", r);
bad = 1;
}
continue;
}
if (r != 0) {
fprintf(stderr, " lookup_le returned %d, expected a hit\n", r);
bad = 1;
break;
}
const struct schfs_key *k = schfs_cursor_key(&c);
if (!k || ref_key_cmp(k, &sorted[expect].key) != 0) {
fprintf(stderr, " lookup_le(%llu,%u,%llu) landed wrong\n",
(unsigned long long)probe.objectid, probe.type,
(unsigned long long)probe.offset);
bad = 1;
}
}
if (bad) {
fprintf(stderr, "FAIL: lookup_le disagrees with the model\n");
failures++;
} else {
printf(" [ok] 200 predecessor lookups match the model\n");
}
}
/* ---- 4. crash safety ---- */
{
struct memdev *d = &dev_storage;
md_commit(d, &t);
uint64_t saved_root = t.root;
uint32_t saved_crc = t.root_crc;
uint64_t saved_gen = t.generation;
/* Snapshot what the committed tree contains. */
static struct ref_ent snap[MAXREF];
int nsnap = 0;
for (int i = 0; i < nref; i++)
if (ref[i].live) snap[nsnap++] = ref[i];
/* Now do a pile of work and then throw the writes away. */
for (int op = 0; op < 500; op++) {
struct schfs_key k;
memset(&k, 0, sizeof(k));
k.objectid = 1000 + rnd() % 500;
k.type = 1;
k.offset = rnd() % 100;
unsigned char val[32];
memset(val, (int)(rnd() & 0xff), sizeof(val));
schfs_btree_insert(&t, &CTX, &k, val, sizeof(val));
}
md_rollback(d);
t.root = saved_root;
t.root_crc = saved_crc;
t.generation = saved_gen;
/* The committed tree must be intact and unchanged. */
nref = 0;
for (int i = 0; i < nsnap; i++) ref[nref++] = snap[i];
if (verify(&t, "after simulated crash")) {
fprintf(stderr, "FAIL: tree did not survive rollback\n");
failures++;
} else {
printf(" [ok] tree intact after discarding 500 uncommitted ops\n");
}
}
/* ---- 5. corruption is detected, not silently returned ---- */
{
struct memdev *d = &dev_storage;
unsigned char save[SCHFS_BLKSIZE];
uint64_t victim = t.root;
memcpy(save, d->blk[victim], SCHFS_BLKSIZE);
d->blk[victim][sizeof(struct schfs_node_hdr) + 4] ^= 0xff;
int r = schfs_btree_check(&t);
if (r != -2) {
fprintf(stderr, "FAIL: flipped a bit in the root and the tree "
"still verified (got %d, wanted -2)\n", r);
failures++;
} else {
printf(" [ok] bit flip in a node is caught by the checksum\n");
}
memcpy(d->blk[victim], save, SCHFS_BLKSIZE);
/* A block written to the wrong address still checksums correctly --
only the self-pointer catches it. */
uint64_t elsewhere = md_alloc(d);
if (elsewhere) {
memcpy(d->blk[elsewhere], d->blk[victim], SCHFS_BLKSIZE);
char tmp[SCHFS_BLKSIZE];
if (node_read(&bd, elsewhere, 0, tmp) != -2) {
fprintf(stderr, "FAIL: misdirected write not detected\n");
failures++;
} else {
printf(" [ok] misdirected write caught by the self-pointer\n");
}
}
}
printf("\n%s\n", failures ? "FAILED" : "all checks passed");
return failures ? 1 : 0;
}

19
tools/sign.pl Normal file
View file

@ -0,0 +1,19 @@
#!/usr/bin/perl
open(SIG, $ARGV[0]) || die "open $ARGV[0]: $!";
$n = sysread(SIG, $buf, 1000);
if($n > 510){
print STDERR "boot block too large: $n bytes (max 510)\n";
exit 1;
}
print STDERR "boot block is $n bytes (max 510)\n";
$buf .= "\0" x (510-$n);
$buf .= "\x55\xAA";
open(SIG, ">$ARGV[0]") || die "open >$ARGV[0]: $!";
print SIG $buf;
close SIG;

134
tools/splashshot.ps1 Normal file
View file

@ -0,0 +1,134 @@
# Photograph the boot splash.
#
# The splash is on screen from the moment stage2 draws it until
# console_takeover() clears it at the first exec(), which is a window of a
# couple of seconds -- so a single screendump at a guessed delay is a coin
# flip. This takes a burst instead and keeps every frame, which makes the
# question "what does boot actually look like, second by second" answerable
# rather than a matter of catching it at the right instant.
#
# Same shape as tools/crashshot.ps1: monitor on TCP because QEMU on Windows has
# no unix sockets, serial to a logfile so the transcript survives.
#
# tools\splashshot.ps1 frames every 400ms for 8s
# tools\splashshot.ps1 -Every 200 -For 4
param(
[int]$Every = 400, # milliseconds between frames
[int]$For = 8, # seconds to keep capturing
[string]$OutDir = "build\shots",
[string]$Qemu = "$env:USERPROFILE\scoop\apps\qemu\current\qemu-system-x86_64.exe"
)
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$log = Join-Path $root 'build\splashshot.log'
$dir = Join-Path $root $OutDir
Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force $dir | Out-Null
Remove-Item -Force $log -ErrorAction SilentlyContinue
$hda = Join-Path $root 'schunix.img'
$hdb = Join-Path $root 'build\fs.img'
$monPort = 55556 # not crashshot's port, so the two can run back to back
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Qemu
$psi.Arguments = @(
# -snapshot with file.locking=off, rather than booting from copies. QEMU
# takes an exclusive lock on its drive images, so an interactive session the
# developer already has open would otherwise make this fail with "Failed to
# get write lock" -- and a capture script is exactly the thing you want to
# run without first shutting down what you were looking at. Copying was the
# first fix and it meant duplicating build\fs.img, all 153 MB of it, on
# every run. -snapshot keeps writes in a temporary overlay, so the originals
# are untouched either way.
"-drive file=`"$hda`",format=raw,index=0,media=disk,file.locking=off"
"-drive file=`"$hdb`",format=raw,index=1,media=disk,file.locking=off"
'-snapshot'
'-m 1G -smp 1 -cpu qemu64,+rdtscp -machine pc-i440fx-9.2'
'-display none'
"-chardev stdio,id=c0,logfile=`"$log`""
'-serial chardev:c0'
"-monitor tcp:127.0.0.1:$monPort,server,nowait"
) -join ' '
$psi.RedirectStandardInput = $true
$psi.UseShellExecute = $false
Write-Host "==> booting" -ForegroundColor Cyan
$p = [System.Diagnostics.Process]::Start($psi)
$frames = @()
try {
# Connect immediately -- the interesting frames are the early ones, so any
# fixed warm-up sleep here is time spent missing them.
$client = $null
for ($i = 0; $i -lt 40 -and -not $client; $i++) {
try { $client = New-Object System.Net.Sockets.TcpClient('127.0.0.1', $monPort) }
catch { Start-Sleep -Milliseconds 100 }
}
if (-not $client) { throw "could not reach the QEMU monitor on port $monPort" }
$writer = New-Object System.IO.StreamWriter($client.GetStream())
$writer.AutoFlush = $true
$n = [int](($For * 1000) / $Every)
$sw = [System.Diagnostics.Stopwatch]::StartNew()
for ($i = 0; $i -lt $n; $i++) {
$ms = [int]$sw.ElapsedMilliseconds
$f = Join-Path $dir ("{0:d5}ms.ppm" -f $ms)
$writer.WriteLine("screendump $f")
$frames += $f
Start-Sleep -Milliseconds $Every
}
$writer.WriteLine('quit')
Start-Sleep -Milliseconds 500
$client.Close()
} finally {
if (-not $p.HasExited) { $p.Kill() }
Start-Sleep -Milliseconds 800
}
# Report each frame by its distinct-colour count. Two colours means a text
# console (or a blank screen); an anti-aliased logo pushes it into the dozens.
# The same reason the full-stack harness counted pixels rather than log lines: a capture
# that exists is not a capture that drew anything.
Write-Host "==> frames" -ForegroundColor Cyan
$kept = 0
foreach ($f in $frames) {
if (-not (Test-Path $f)) { continue }
$kept++
$b = [System.IO.File]::ReadAllBytes($f)
# PPM P6: "P6\n<w> <h>\n255\n" then raw RGB. Skip three whitespace-run
# separated header fields rather than assuming a fixed header length --
# QEMU's is compact but that is not guaranteed.
$pos = 2; $fields = 0
while ($fields -lt 3 -and $pos -lt $b.Length) {
while ($pos -lt $b.Length -and [char]$b[$pos] -match '\s') { $pos++ }
while ($pos -lt $b.Length -and [char]$b[$pos] -notmatch '\s') { $pos++ }
$fields++
}
$pos++
$seen = @{}
for ($i = $pos; $i + 2 -lt $b.Length; $i += 3 * 7) { # sample every 7th pixel
$seen[($b[$i] -shl 16) -bor ($b[$i+1] -shl 8) -bor $b[$i+2]] = 1
}
$name = Split-Path $f -Leaf
$c = $seen.Count
$tag = if ($c -ge 16) { 'graphical' } elseif ($c -le 3) { 'flat/text' } else { 'sparse' }
$col = if ($c -ge 16) { 'Green' } else { 'DarkGray' }
Write-Host (" {0,-12} {1,4} colours {2}" -f $name, $c, $tag) -ForegroundColor $col
}
if ($kept -eq 0) { Write-Host "FAIL: no frames captured" -ForegroundColor Red; exit 1 }
Write-Host "==> $kept frames in $dir" -ForegroundColor Green
# Convert to PNG if ImageMagick is around, so they can just be looked at.
if (Get-Command magick -ErrorAction SilentlyContinue) {
foreach ($f in $frames) {
if (Test-Path $f) { & magick $f ([System.IO.Path]::ChangeExtension($f, '.png')) }
}
Write-Host "==> PNGs alongside" -ForegroundColor Green
}

116
tools/textshot.ps1 Normal file
View file

@ -0,0 +1,116 @@
# Boot Schunix, run the text-rendering self-test, and capture the result as an image.
#
# The display class cannot be checked without a display: the assertion is "did
# the right pixels reach the screen", and the only instrument for that is a
# screendump. This drives it through `echo t > /proc/sysrq` and takes a picture.
#
# What the pattern proves: four solid quadrants in theme colours (a stride error
# would slant the bands, a bpp error would change the colours), a white frame one
# pixel inside the edge (an off-by-one in the present rectangle would clip it),
# and the fact that every pixel was drawn into RAM before a single byte reached
# the framebuffer -- which is what double buffering means.
#
# QEMU's monitor is on its own socket so the serial console stays free for the
# shell. screendump writes a PPM; the PNG conversion is optional and skipped if
# ImageMagick is not around.
#
# tools\crashshot.ps1 crash the ramdisk-root build
# tools\crashshot.ps1 -SchFS with a SchFS volume attached
param(
[switch]$SchFS,
[int]$BootSeconds = 20,
[string]$Out = "build\textshot.ppm",
[string]$Qemu = "$env:USERPROFILE\scoop\apps\qemu\current\qemu-system-x86_64.exe"
)
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$hda = Join-Path $root 'schunix.img'
$hdb = if ($SchFS) { Join-Path $root 'build\schfs.img' } else { Join-Path $root 'build\fs.img' }
$log = Join-Path $root 'build\crashshot.log'
$ppm = Join-Path $root $Out
$mon = Join-Path $root 'build\qemu-mon.sock'
Remove-Item -Force $log, $ppm -ErrorAction SilentlyContinue
# The monitor goes to a TCP port rather than a unix socket: PowerShell can open
# a TCP client without any extra machinery, and QEMU on Windows has no unix
# sockets anyway.
$monPort = 55555
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Qemu
$psi.Arguments = @(
"-drive file=`"$hda`",format=raw,index=0,media=disk"
"-drive file=`"$hdb`",format=raw,index=1,media=disk"
'-m 1G -smp 1 -cpu qemu64,+rdtscp -machine pc-i440fx-9.2'
'-display none'
"-chardev stdio,id=c0,logfile=`"$log`""
'-serial chardev:c0'
"-monitor tcp:127.0.0.1:$monPort,server,nowait"
) -join ' '
$psi.RedirectStandardInput = $true
$psi.UseShellExecute = $false
Write-Host "==> booting" -ForegroundColor Cyan
$p = [System.Diagnostics.Process]::Start($psi)
function Send-Line($text) {
foreach ($ch in ($text + "`n").ToCharArray()) {
$p.StandardInput.Write($ch)
$p.StandardInput.Flush()
Start-Sleep -Milliseconds 30 # serial has no flow control
}
}
try {
Start-Sleep -Seconds $BootSeconds
# Show the device list first, so the transcript records what was bound.
Write-Host " > listing devices" -ForegroundColor DarkGray
Send-Line 'cat /proc/devices'
Start-Sleep -Seconds 3
Write-Host " > echo t > /proc/sysrq" -ForegroundColor DarkGray
Send-Line 'echo t > /proc/sysrq'
Start-Sleep -Seconds 4
Write-Host "==> capturing the framebuffer" -ForegroundColor Cyan
$client = New-Object System.Net.Sockets.TcpClient('127.0.0.1', $monPort)
$stream = $client.GetStream()
$writer = New-Object System.IO.StreamWriter($stream)
$writer.AutoFlush = $true
Start-Sleep -Milliseconds 500
# QEMU on Windows wants a native path here, not the one PowerShell shows.
$writer.WriteLine("screendump $ppm")
Start-Sleep -Seconds 2
$writer.WriteLine('quit')
Start-Sleep -Milliseconds 500
$client.Close()
} finally {
if (-not $p.HasExited) { $p.Kill() }
Start-Sleep -Milliseconds 800
}
if (Test-Path $log) {
$text = Get-Content $log -Raw
Write-Host "==> serial transcript" -ForegroundColor Cyan
# Only the crash report, not the whole boot.
$idx = $text.IndexOf('display: presented')
if ($idx -ge 0) { Write-Host $text.Substring($idx) } else { Write-Host $text }
}
if (Test-Path $ppm) {
$size = (Get-Item $ppm).Length
Write-Host "==> $ppm ($size bytes)" -ForegroundColor Green
$png = [System.IO.Path]::ChangeExtension($ppm, '.png')
$magick = Get-Command magick -ErrorAction SilentlyContinue
if ($magick) {
& magick $ppm $png
Write-Host "==> $png" -ForegroundColor Green
}
} else {
Write-Host "FAIL: no screendump was produced" -ForegroundColor Red
exit 1
}

81
tools/ttftest.c Normal file
View file

@ -0,0 +1,81 @@
// Host harness for kernel/dev/ttf.c.
//
// A rasteriser is checked by looking at it. This renders a string with the real
// font and writes a PGM, which is the smallest image format that needs no
// compression -- the point is the glyphs, not the container.
//
// build/tools/ttftest <font.ttf> <px> "text" out.pgm
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define TTF_HOST
#include "../kernel/dev/ttf.c"
int ttf_render(struct ttf *, uint32, struct glyph *, unsigned char *, int, int *, int *);
int main(int argc, char **argv)
{
if (argc < 5) { fprintf(stderr, "usage: ttftest font.ttf px \"text\" out.pgm\n"); return 1; }
const char *path = argv[1];
int px = atoi(argv[2]);
const char *text = argv[3];
FILE *fp = fopen(path, "rb");
if (!fp) { perror(path); return 1; }
fseek(fp, 0, SEEK_END); long n = ftell(fp); fseek(fp, 0, SEEK_SET);
unsigned char *buf = malloc(n);
if (fread(buf, 1, n, fp) != (size_t)n) { fprintf(stderr, "short read\n"); return 1; }
fclose(fp);
struct ttf f;
if (ttf_open(&f, buf, (uint32)n, px) != 0) {
fprintf(stderr, "not a TrueType font this can read\n"); return 1;
}
printf("unitsPerEm %d, %d glyphs, ascent %d descent %d, %s loca\n",
f.units_per_em, f.num_glyphs, f.ascent, f.descent,
f.long_loca ? "long" : "short");
struct font *fo = ttf_font(&f);
int H = fo->ops->height(fo) + px; // room for descenders and padding
int base = fo->ops->baseline(fo) + px / 2;
int W = 16;
for (const char *p = text; *p; p++) W += px; // generous
unsigned char *img = calloc(W * H, 1);
int pen = 8, drawn = 0, missing = 0;
for (const char *p = text; *p; p++) {
struct glyph g;
// Through the provider, not ttf_render, so the glyph cache is on the
// path being tested rather than bypassed by the harness.
if (fo->ops->glyph(fo, (unsigned char)*p, &g) != 0) {
missing++;
pen += px / 2;
continue;
}
for (int gy = 0; gy < g.h; gy++)
for (int gx = 0; gx < g.w; gx++) {
int X = pen + g.bearing_x + gx;
int Y = base - g.bearing_y + gy;
if (X < 0 || Y < 0 || X >= W || Y >= H) continue;
int v = img[Y * W + X] + g.cov[gy * g.w + gx];
img[Y * W + X] = v > 255 ? 255 : v;
}
pen += g.advance;
drawn++;
}
// White on black inverted to black on white, which is how text is read.
for (int i = 0; i < W * H; i++) img[i] = 255 - img[i];
FILE *o = fopen(argv[4], "wb");
fprintf(o, "P5\n%d %d\n255\n", W, H);
fwrite(img, 1, W * H, o);
fclose(o);
printf("%d glyphs drawn, %d missing, %d px advance, image %dx%d -> %s\n",
drawn, missing, pen - 8, W, H, argv[4]);
return drawn ? 0 : 1;
}

92
tools/ttyshot.ps1 Normal file
View file

@ -0,0 +1,92 @@
# Photograph the framebuffer console after driving it with escape sequences.
#
# The serial line and the framebuffer are two different consumers of console
# output, and they behave differently on purpose: escapes are passed through
# to the serial line verbatim (a real terminal emulator is on the other end
# and understands them better than we do) and *interpreted* for the
# framebuffer. So the serial transcript can never tell you whether the escape
# parser works -- it contains the raw bytes either way. Only a picture can.
#
# tools\ttyshot.ps1
param(
[int]$BootSeconds = 22,
[string]$Out = "build\ttyshot.ppm",
[string]$Qemu = "$env:USERPROFILE\scoop\apps\qemu\current\qemu-system-x86_64.exe"
)
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$hda = Join-Path $root 'schunix.img'
$hdb = Join-Path $root 'build\fs.img'
$log = Join-Path $root 'build\ttyshot.log'
$ppm = Join-Path $root $Out
$monPort = 55571
# Each line exercises one thing the console could not do before: colour,
# bright colour, background colour, cursor addressing, erase-to-end-of-line,
# and non-ASCII text.
$commands = @(
'clear'
'printf ''\033[31mRED \033[32mGREEN \033[34mBLUE \033[35mMAGENTA \033[36mCYAN \033[0mdefault\n'''
'printf ''\033[1;31mbold red \033[1;32mbold green \033[1;34mbold blue\033[0m\n'''
'printf ''\033[37;44m white on blue \033[0m normal\n'''
'printf ''caf\303\251 \303\251t\303\251 na\303\257ve \303\274ber sch\303\266n a\303\261o\n'''
'printf ''ERASED-THIS\033[13G\033[Kkept this\n'''
'printf ''\033[12;40Hcursor addressed to row 12 col 40\n'''
# Tab used to be drawn as glyph 9, which in CP437 is a hollow circle --
# so pressing Tab printed a circle instead of moving the cursor. Every
# other control character had the same problem waiting behind it.
'printf ''A\tB\tC\tD -- tabs, not circles\n'''
)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Qemu
$psi.Arguments = @(
"-drive file=`"$hda`",format=raw,index=0,media=disk"
"-drive file=`"$hdb`",format=raw,index=1,media=disk"
'-m 1G -smp 1 -cpu qemu64,+rdtscp -machine pc-i440fx-9.2'
'-display none'
"-chardev stdio,id=c0,logfile=`"$log`""
'-serial chardev:c0'
"-monitor tcp:127.0.0.1:$monPort,server,nowait"
) -join ' '
$psi.RedirectStandardInput = $true
$psi.UseShellExecute = $false
Write-Host '==> booting' -ForegroundColor Cyan
$p = [System.Diagnostics.Process]::Start($psi)
try {
Start-Sleep -Seconds $BootSeconds
foreach ($cmd in $commands) {
Write-Host " > $cmd" -ForegroundColor DarkGray
foreach ($ch in ($cmd + "`n").ToCharArray()) {
$p.StandardInput.Write($ch)
$p.StandardInput.Flush()
Start-Sleep -Milliseconds 20 # serial has no flow control
}
Start-Sleep -Seconds 2
}
Write-Host '==> capturing the framebuffer' -ForegroundColor Cyan
$client = New-Object System.Net.Sockets.TcpClient('127.0.0.1', $monPort)
$writer = New-Object System.IO.StreamWriter($client.GetStream())
$writer.AutoFlush = $true
Start-Sleep -Milliseconds 500
$writer.WriteLine("screendump $ppm")
Start-Sleep -Seconds 2
$writer.WriteLine('quit')
Start-Sleep -Milliseconds 500
$client.Close()
} finally {
if (-not $p.HasExited) { $p.Kill() }
Start-Sleep -Milliseconds 800
}
if (Test-Path $ppm) {
Write-Host "==> $ppm ($((Get-Item $ppm).Length) bytes)" -ForegroundColor Green
} else {
Write-Host 'FAIL: no screendump was produced' -ForegroundColor Red
exit 1
}

27
tools/vectors.pl Normal file
View file

@ -0,0 +1,27 @@
#!/usr/bin/perl -w
# Generate vectors.S, the trap/interrupt entry points.
# There has to be one entry point per interrupt number
# since otherwise there's no way for trap() to discover
# the interrupt number.
print "# generated by vectors.pl - do not edit\n";
print "# handlers\n";
print ".global alltraps\n";
for(my $i = 0; $i < 256; $i++){
# print ".global vector$i\n"; # might be useful for debugging
print "vector$i:\n";
if(!($i == 8 || ($i >= 10 && $i <= 14) || $i == 17)){
print " push \$0\n";
}
print " push \$$i\n";
print " jmp alltraps\n";
}
print "\n# vector table\n";
print ".data\n";
print ".global vectors\n";
print "vectors:\n";
for(my $i = 0; $i < 256; $i++){
print " .quad vector$i\n";
}