# BST-TIMEOUT: 300 # 33 cases, measured 34s on a quiet local guest (==BST-SUITE, 2026-08-16). # # The default 60 is only ~2x that, and every case execs a 20 MB wasm module, # # so a loaded nightly host would trip it; 300 is ~9x the measurement. . "$BST_LIB" # # lastlog2 1.2.0 -- the SQLite-backed replacement for lastlog(8). # # WHAT THIS SUITE IS ACTUALLY TESTING, because "does lastlog2 run" would be worth # very little. lastlog2 is the first consumer on this image that reaches SQLite # through LIBLASTLOG2 rather than through /usr/bin/sqlite3, and it reaches it for # a database ON DISK. That matters here: the default unix VFS cannot lock any # file-backed database on this kernel (brintos/linux#16 -- sys_fcntl64 is unwired, # so every LFS F_SETLK64 returns EINVAL), which is why the image compiles # libsqlite3 with SQLITE_DEFAULT_UNIX_VFS="unix-dotfile" # (recipes-support/sqlite/sqlite3_%.bbappend). Every case below that writes and # then re-reads a database is therefore a second, independent witness for that # workaround -- and the only one that exercises it through the LIBRARY. If the # dotfile default were ever dropped, 77-sqlite3-file and this suite would go red # together. # # THE PROCESS BOUNDARY IS THE ASSERTION, and here it is free rather than # contrived: lastlog2 is a one-shot CLI, so every case below is already a fresh # process opening the database from disk. A write in one invocation that is # visible in the next is durability, not a page cache. # # --------------------------------------------------------------------------- # WHY THE DATABASE IS SEEDED WITH sqlite3 RATHER THAN WRITTEN BY THE TOOL. # --------------------------------------------------------------------------- # This is the one thing about this file that looks like a shortcut and is not. # lastlog2 has exactly three ways to INSERT a row, and on this image none of them # is available to a test: # # 1. pam_lastlog2.so, the module the PAM stack calls at login. Not on this # image, and not by omission: a PAM module is dlopen'd and this arm has no # dlopen, so the recipe's patch drops it rather than shipping an archive # nothing can load. # 2. `lastlog2 -i`, the legacy-lastlog import. BLOCKED BY A PLATFORM DEFECT -- # it enumerates the passwd database with getpwent(3) and glibc's # __nss_getent traps here. That is what the import cases below assert, and # they are left RED on purpose; see the block above them. # 3. `lastlog2 -u USER -S`. Cannot bootstrap: ll2_update_login_time() READS the # row before it writes one, and read_entry() fails both when the table is # absent and when the user has no row ("User 'x' not found (101)"). So -S # updates an existing record and can never create one -- upstream behaviour, # reproduced here, not a local breakage. # # So the alternative to seeding is not "a better test", it is no coverage at all # of read-back, -S, -r or -C. The seed is one INSERT through the other SQLite # consumer on the image, and everything asserted after it goes through # liblastlog2. What the seed does NOT do is stand in for the write path: the two # genuine write entry points are covered where they can be (the -S update, the # rename and the clear all write through the library) and reported as blocked # where they cannot (import). # # THE FIXTURE LAYOUT IS COMPUTED, NOT SHIPPED, because it is a property of THIS # TARGET's libc and a checked-in blob would silently stop matching. struct lastlog # on wasm32-hwjs glibc is # { int64 ll_time; char ll_line[32]; char ll_host[256]; } = 296 bytes # -- 64-bit because bits/timesize.h sets __TIMESIZE 64 while __WORDSIZE is 32, so # bits/typesizes.h takes its y2038 branch and __TIME_T_TYPE is __SQUAD_TYPE. The # record for a user lives at uid * 296, which is the sparse-file indexing scheme # lastlog2 exists to replace. # # THE TIMESTAMP IS CHOSEN SO ITS BYTES ARE ASCII-SAFE. 1700003840 is 0x65540000, # whose four little-endian bytes are 00 00 54 65 -- all below 0x80. A byte >= 0x80 # written through printf or awk's %c can be re-encoded as UTF-8 by a non-C locale # and silently corrupt the record, which would look like a lastlog2 bug and is not. require lastlog2 lastlog2/present TZ=UTC; export TZ # print_entry uses localtime(); pin it so the date is assertable LC_ALL=C; export LC_ALL T=${TMPDIR:-/tmp}/bst-lastlog2.$$ rm -rf "$T"; mkdir -p "$T" # Every case below is relative to $T, so a temp directory that is not writable # would report as ~25 confusing reds. Say it once instead. check lastlog2/tmp-writable sh -c "printf x > $T/.w && rm -f $T/.w" DB=$T/ll2.db FIX=$T/lastlog.legacy REC=296 # sizeof(struct lastlog) on this arm -- see header T1=1700003840; D1="Tue Nov 14 23:17:20 +0000 2023" TTY1=pts/7; HOST1=brintos-test.example SCHEMA="Name TEXT PRIMARY KEY, Time INTEGER, TTY TEXT, RemoteHost TEXT, Service TEXT" # --------------------------------------------------------- 0. pick real accounts # lastlog2 resolves every user through getpwnam/getpwent and -u/-C/-S all refuse a # name the passwd database does not carry, so the suite drives EXISTING accounts # read out of /etc/passwd rather than adding its own: 06-base-passwd.test asserts # the exact content of that file, and a suite that appended to it would break that # one and would not survive a re-run. uid 0 is the entry guaranteed to be there. U1=$(awk -F: '$3==0{print $1; exit}' /etc/passwd) U2=$(awk -F: '$3>0 && $3<64 {print $1; exit}' /etc/passwd) check_eq lastlog2/passwd-root root "$U1" # --------------------------------------------------------------- 1. the CLI runs check_out lastlog2/version "1.2.0" lastlog2 -v check_out lastlog2/help "--import" lastlog2 -h # An unknown user is refused by NAME, which is the getpwnam path -- the one NSS # entry point that does work here, and the control for the getpwent trap below. check_out lastlog2/unknown-user "does not exist" sh -c "lastlog2 -d '$DB' -u no-such-user-here 2>&1; :" # ------------------------------------------------- 2. a user with no record yet check_out lastlog2/never-logged-in "**Never logged in**" lastlog2 -d "$DB" -u "$U1" # ------------------------------------------------ 3. the legacy-lastlog import # EXPECTED RED, and left red deliberately -- this is the regression signal that # says the platform defect below is fixed. The fixture and the database are both # fine; what fails is glibc's passwd ENUMERATION: # # RuntimeError: null function or function signature mismatch # at lastlog2.__nss_getent.resume (wasm-function[6853]) # Illegal instruction (rc=132) # # ll2_import_lastlog() walks setpwent()/getpwent() to map each uid in the legacy # file to a name. getpwnam() works on this image (lastlog2/unknown-user above, and # base-passwd's nss-getpwnam), so this is specific to the enumeration entry point # and is NOT the old __nss_module_load trap, which was brintos/linux-in-the-browser#92 # and is closed. Established by a one-variable A/B in the same binary against the # same database, with the table pre-created so SQLite is not in the picture: # -u root returns 0, -i traps. # # BRINTOS-WORKAROUND: brintos/linux-in-the-browser#164 remove — getpwent() traps in # __nss_getent on the wasm32-hwjs glibc arm, so `lastlog2 -i` cannot enumerate the # passwd database; drop this block's expected-red framing when #164 closes. ll2_maxuid=$(awk -F: -v a="$U1" '$1==a{print $3+0}' /etc/passwd) dd if=/dev/zero of="$FIX" bs=$REC count=$((ll2_maxuid + 1)) 2>/dev/null _o=$(( ll2_maxuid * REC )) printf '\000\000\124\145\000\000\000\000' | dd of="$FIX" bs=1 seek=$_o conv=notrunc 2>/dev/null printf '%s' "$TTY1" | dd of="$FIX" bs=1 seek=$(( _o + 8 )) conv=notrunc 2>/dev/null printf '%s' "$HOST1" | dd of="$FIX" bs=1 seek=$(( _o + 40 )) conv=notrunc 2>/dev/null check_eq lastlog2/fixture-size $(( (ll2_maxuid + 1) * REC )) "$(wc -c < "$FIX")" check lastlog2/import lastlog2 -d "$DB" -i "$FIX" # ------------------------------------------------------- 4. seed, then round-trip # See the header for why the seed exists. Everything asserted after it is # liblastlog2 reading and writing a real database on disk. if command -v sqlite3 >/dev/null 2>&1; then rm -f "$DB" sqlite3 "$DB" "CREATE TABLE Lastlog2($SCHEMA) STRICT; INSERT INTO Lastlog2 VALUES('$U1',$T1,'$TTY1','$HOST1',NULL);" 2>/dev/null # The seed produced a real SQLite database, read independently of the tool: the # format-3 magic is the first 15 bytes of the header. check_out lastlog2/db-magic "SQLite format 3" sh -c "head -c 15 '$DB'" # The three fields the ticket's round trip is about, asserted separately so a # partial failure names which one -- a wrong time is a different defect from a # truncated tty. This is a DIFFERENT PROCESS from the one that wrote them. check_out lastlog2/read-tty "$TTY1" lastlog2 -d "$DB" -u "$U1" check_out lastlog2/read-host "$HOST1" lastlog2 -d "$DB" -u "$U1" check_out lastlog2/read-time "$D1" lastlog2 -d "$DB" -u "$U1" # The header is part of the contract too -- a reader parses these columns. check_out lastlog2/header "Username Port From" lastlog2 -d "$DB" -u "$U1" # No -u lists every row through ll2_read_all, a different code path. check_out lastlog2/read-all "$HOST1" lastlog2 -d "$DB" # ---------------------------------------------------- 4a. the -b / -t filters # Both are computed against time(NULL) as days*86400, so they assert the stored # value is a real epoch and not a string that echoed back. The record is from # 2023: older than a day, not newer than one. check_out lastlog2/before-shows "$HOST1" lastlog2 -d "$DB" -u "$U1" -b 1 check_eq lastlog2/recent-hides "" "$(lastlog2 -d "$DB" -u "$U1" -t 1)" # ------------------------------------------------------- 4b. -S updates a row # ll2_update_login_time: read the row, rewrite it with a new time. It exits 0 # only because of the recipe's 0004 patch -- upstream frees an uninitialized # pam_service whenever Service is NULL, which is every row not written by the # PAM module, and aborts `free(): invalid size` (rc=127) AFTER committing. So a # non-zero rc here with the time nonetheless updated means that patch was lost. check lastlog2/set-now lastlog2 -d "$DB" -u "$U1" -S check_out lastlog2/set-now-year "$(date +%Y)" lastlog2 -d "$DB" -u "$U1" # ...and the freshly-set record now passes the -t filter that excluded the old one. check_out lastlog2/recent-shows-after-set "$U1" sh -c "lastlog2 -d '$DB' -u '$U1' -t 1" # -S updates ONLY the time: tty, host and service are read back and rewritten # unchanged. "The time changed" alone would also be true of an implementation # that dropped them, and a lost tty/host is silent data loss on every login. check_out lastlog2/set-now-keeps-tty "$TTY1" lastlog2 -d "$DB" -u "$U1" check_out lastlog2/set-now-keeps-host "$HOST1" lastlog2 -d "$DB" -u "$U1" # --------------------------------------------------------- 4c. rename a user # ll2_rename_user writes the row under the new name and removes the old one, so # both halves are asserted -- a rename that only copied would leave a duplicate. if [ -n "$U2" ]; then check lastlog2/rename lastlog2 -d "$DB" -u "$U1" -r "$U2" check_out lastlog2/rename-moved "$HOST1" lastlog2 -d "$DB" -u "$U2" check_out lastlog2/rename-gone "**Never logged in**" lastlog2 -d "$DB" -u "$U1" check lastlog2/rename-back lastlog2 -d "$DB" -u "$U2" -r "$U1" else for c in rename rename-moved rename-gone rename-back; do bst_skip lastlog2/$c "no second account with 0 < uid < 64 in /etc/passwd" done fi # ------------------------------------------------------ 4d. expire an entry # The ticket's last requirement: clear a record and confirm it is gone. "Gone" # is specifically **Never logged in** -- ll2_read_entry finds no row, ll_time # stays 0, and print_entry says so. check lastlog2/clear lastlog2 -d "$DB" -u "$U1" -C check_out lastlog2/clear-gone "**Never logged in**" lastlog2 -d "$DB" -u "$U1" # Durable, not just absent from this process's view: the bytes on disk must not # still carry the name, read back with the other SQLite consumer on the image. check_eq lastlog2/clear-ondisk 0 \ "$(sqlite3 "$DB" "select count(*) from Lastlog2 where Name='$U1';" 2>/dev/null)" # Clearing an already-clear user is not an error -- DELETE of zero rows succeeds. check lastlog2/clear-idempotent lastlog2 -d "$DB" -u "$U1" -C else for c in db-magic read-tty read-host read-time header read-all before-shows \ recent-hides set-now set-now-year recent-shows-after-set \ set-now-keeps-tty set-now-keeps-host rename rename-moved rename-gone \ rename-back clear clear-gone clear-ondisk clear-idempotent; do bst_skip lastlog2/$c "sqlite3 shell not on this image, and no lastlog2 entry point can create a row here (see header)" done fi # ------------------------------------------- 5. the shipped default database # /var/lib/lastlog is created by the recipe. Upstream leaves it to a tmpfiles.d # snippet, and this image's init is busybox, so nothing would ever apply it; # without the directory the tool cannot open its own default path while every # explicit -d path under an existing directory works. This is the case that # catches that regression -- but only where the rootfs is writable: an anonymous # browser session mounts it read-only, which is an environment fact, not a defect. check lastlog2/default-dir-exists test -d /var/lib/lastlog if touch /var/lib/lastlog/.bst-w 2>/dev/null; then rm -f /var/lib/lastlog/.bst-w DDB=/var/lib/lastlog/lastlog2.db if command -v sqlite3 >/dev/null 2>&1 && [ ! -e "$DDB" ]; then sqlite3 "$DDB" "CREATE TABLE Lastlog2($SCHEMA) STRICT; INSERT INTO Lastlog2 VALUES('$U1',$T1,'$TTY1','$HOST1',NULL);" 2>/dev/null check lastlog2/default-db-set lastlog2 -u "$U1" -S check_out lastlog2/default-db-read "$(date +%Y)" lastlog2 -u "$U1" check lastlog2/default-db-file test -s "$DDB" rm -f "$DDB" else for c in default-db-set default-db-read default-db-file; do bst_skip lastlog2/$c "no sqlite3 to seed with, or a real lastlog2.db is already present and must not be clobbered" done fi else for c in default-db-set default-db-read default-db-file; do bst_skip lastlog2/$c "/var/lib/lastlog is not writable on this mount (read-only rootfs)" done fi rm -rf "$T"