Skip to content

Commit d504614

Browse files
committed
Harden maintenance scripts and add regression tests
- rebuild-feeds: decode HTML entities before XML escaping (no double encoding), skip index.html by basename, atomic writes, and an optional --base-url while keeping stable root-relative GUIDs. - update-feeds/update-articles: validate slugs, atomic writes, precise process exit reporting. - validate-website: add --check-all; keep link checks offline by default (--check-links-external to opt in); avoid a false positive on JavaScript new URL(...); use POSIX::uname instead of a shell call. - rebuild-sitemap: atomic write. - html2ogg: mktemp for the temporary WAV, errors to stderr. - styles.css: deduplicate selectors, fix print colours, add WebKit text-size-adjust fallback. - Add t/ regression tests (prove), .stylelintrc.json, .htmltest.yml and .gitignore. - Regenerate feeds and sitemap; update the article index and tool list.
1 parent a71b123 commit d504614

27 files changed

Lines changed: 1746 additions & 113 deletions

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
tmp/

‎.htmltest.yml‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Configuration for htmltest (https://github.com/wjdp/htmltest).
2+
# Offline checks only: links, anchors, images and metadata. External
3+
# URLs are intentionally NOT requested here; use lychee for that.
4+
# Run from the repository root, e.g. `htmltest -c .htmltest.yml`.
5+
DirectoryPath: "."
6+
CheckDoctype: true
7+
CheckAnchors: true
8+
CheckLinks: true
9+
CheckInternal: true
10+
CheckExternal: false
11+
CheckImages: true
12+
CheckMeta: true
13+
CheckGeneric: true
14+
CheckMailto: false
15+
CheckTel: false
16+
IgnoreURLs:
17+
- "onion"
18+
- "i2p"
19+
- "mailto:"
20+
- "tel:"
21+
- "bitcoin:"
22+
- "monero:"

‎.stylelintrc.json‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"rules": {
3+
"at-rule-no-unknown": true,
4+
"block-no-empty": true,
5+
"color-no-invalid-hex": true,
6+
"declaration-block-no-duplicate-properties": [
7+
true,
8+
{
9+
"ignore": [
10+
"consecutive-duplicates-with-different-syntaxes"
11+
]
12+
}
13+
],
14+
"function-calc-no-unspaced-operator": true,
15+
"media-feature-name-no-unknown": true,
16+
"no-duplicate-selectors": true,
17+
"no-invalid-double-slash-comments": true,
18+
"property-no-unknown": true,
19+
"selector-pseudo-class-no-unknown": true,
20+
"selector-pseudo-element-no-unknown": true,
21+
"string-no-newline": true,
22+
"unit-no-unknown": true
23+
}
24+
}

‎articles/c-language.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -603,7 +603,7 @@ <h2 id="c-references">References</h2>
603603
Memory Safe Languages: Reducing Vulnerabilities in ...</a>
604604
</li>
605605

606-
<li>[58] <a href="http://arxiv.org/pdf/2504.03863.pdf">The Secret
606+
<li>[58] <a href="https://arxiv.org/pdf/2504.03863.pdf">The Secret
607607
Life of CVEs</a>
608608
</li>
609609

‎articles/qubes-os.html‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -637,11 +637,11 @@ <h2 id="qubes-5">Systemd attack surface</h2>
637637
</li>
638638

639639
<li>Suckless developers on systemd: <a href=
640-
"http://suckless.org/sucks/systemd/">suckless project page</a>
640+
"https://suckless.org/sucks/systemd/">suckless project page</a>
641641
</li>
642642

643-
<li>Broken by default: <a href="http://ewontfix.com/14/">ewontfix.com
644-
article</a>
643+
<li>Broken by default: <a href=
644+
"https://ewontfix.com/14/">ewontfix.com article</a>
645645
</li>
646646

647647
<li>The world after systemd: <a href=

‎data/articles.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
{
2+
"openbsd-hardening": {
3+
"en": "openbsd-hardening.html",
4+
"pubdate": "2026-10-08",
5+
"title_en": "OpenBSD desktop hardening guide"
6+
},
27
"searxng-on-openbsd": {
38
"en": "searxng-on-openbsd.html",
49
"pubdate": "2026-06-08",

‎feeds/articles.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<item>
1010
<title>OpenBSD desktop hardening guide</title>
1111
<link>/articles/openbsd-hardening.html</link>
12-
<description>A practical desktop-focused guide to hardening OpenBSD, covering privilege separation, file permissions, PF firewall configuration, kernel settings, securelevel, DNS, X11, and userland sandboxing.</description>
12+
<description>A practical OpenBSD desktop hardening guide covering privilege separation, file permissions, PF firewall rules, system settings, DNS, X11, and userland sandboxing.</description>
1313
<pubDate>Thu, 08 Oct 2026 00:00:00 GMT</pubDate>
1414
<guid isPermaLink="false">/articles/openbsd-hardening.html</guid>
1515
</item>

‎scripts/html2ogg.sh‎

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,13 @@ set -e
2424

2525
require_cmd() {
2626
if ! command -v "$1" >/dev/null 2>&1; then
27-
echo "[ERROR] $1 is not installed"
27+
echo "[ERROR] $1 is not installed" >&2
2828
exit 1
2929
fi
3030
}
3131

3232
check_dependencies() {
33+
require_cmd mktemp
3334
require_cmd pandoc
3435
require_cmd espeak-ng
3536
require_cmd ffmpeg
@@ -43,7 +44,7 @@ parse_args() {
4344

4445
HTML=$1
4546
if [ ! -f "$HTML" ]; then
46-
echo "[ERROR] File not found: $HTML"
47+
echo "[ERROR] File not found: $HTML" >&2
4748
exit 1
4849
fi
4950
}
@@ -65,28 +66,29 @@ select_language() {
6566
SPEED=140
6667
;;
6768
*)
68-
echo "[ERROR] Invalid option"
69+
echo "[ERROR] Invalid option" >&2
6970
exit 1
7071
;;
7172
esac
7273
}
7374

7475
create_temp_wav() {
75-
TMP_WAV=
76-
i=0
77-
while :; do
78-
TMP_WAV=${TMPDIR:-/tmp}/voice$$-$i.wav
79-
if (umask 077 && : >"$TMP_WAV") 2>/dev/null; then
80-
break
81-
fi
82-
i=$((i + 1))
83-
done
76+
# mktemp creates the file with an unpredictable name and mode 0600,
77+
# using O_EXCL, which avoids races and symlink attacks.
78+
TMP_WAV=$(mktemp "${TMPDIR:-/tmp}/voice-XXXXXX") || {
79+
echo "[ERROR] Could not create temporary file" >&2
80+
exit 1
81+
}
8482
}
8583

8684
convert_html_to_ogg() {
8785
OUT="${HTML%.*}.ogg"
8886
echo "Converting HTML to audio..."
89-
pandoc "$HTML" -t plain --wrap=none |
87+
# Run pandoc as a separate, checked command so a text
88+
# extraction failure aborts the conversion instead of feeding
89+
# empty input to espeak-ng (POSIX sh has no pipefail).
90+
text=$(pandoc "$HTML" -t plain --wrap=none)
91+
printf '%s\n' "$text" |
9092
espeak-ng -v "$VOICE" -s "$SPEED" \
9193
-p 50 -w "$TMP_WAV"
9294
ffmpeg -y -loglevel error -i "$TMP_WAV" \

‎scripts/openbsd-tools.txt‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# Mapping of the tools used by scripts/ to OpenBSD ports.
2+
# Format: tool = port (or a short note when there is no port entry).
3+
#
4+
# Required by scripts/validate-website.pl:
15
tidy = www/tidy
26
xmllint = textproc/libxml
37
dprint = not ported
@@ -8,3 +12,15 @@ rsvg-convert = x11/gnome/librsvg
812
pandoc = textproc/pandoc
913
ffmpeg = graphics/ffmpeg
1014
espeak-ng = audio/espeak
15+
#
16+
# Optional, used only by `validate-website.pl --check-all`. None of
17+
# these are required to build, serve, or maintain the site; when a
18+
# tool is absent the corresponding check is reported and skipped.
19+
# They are not shipped with OpenBSD and have to be installed by the
20+
# means provided by each project.
21+
prove = (part of the base Perl installation)
22+
shellcheck = shells/shellcheck
23+
vnu = (external, needs Java)
24+
stylelint = (external, needs Node.js)
25+
htmltest = (external, needs Go or a release binary)
26+
lychee = (external, needs Rust or a release binary)

‎scripts/rebuild-feeds.pl‎

Lines changed: 102 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@
3535
use utf8;
3636

3737
use Cwd qw(abs_path);
38-
use File::Basename qw(basename);
38+
use File::Basename qw(basename dirname);
3939
use File::Find;
4040
use File::Spec;
41+
use File::Temp qw(tempfile);
42+
use Getopt::Long qw(GetOptions);
4143
use Time::Local qw(timegm);
4244

4345
my $script_dir = ( File::Spec->splitpath($0) )[1];
@@ -50,6 +52,14 @@
5052
$feeds_dir = File::Spec->catdir( $root_dir, 'feeds' );
5153
$articles_dir = File::Spec->catdir( $root_dir, 'articles' );
5254

55+
# Optional deployment base URL. When empty (the default) links stay
56+
# root-relative so a single feed works on every mirror (HTTPS, Tor,
57+
# I2P). GUIDs always stay relative and therefore stable.
58+
my $base_url = '';
59+
GetOptions( 'base-url=s' => \$base_url )
60+
or die "Usage: $0 [--base-url https://example.org]\n";
61+
$base_url =~ s{/+$}{} if length $base_url;
62+
5363
binmode STDOUT, ':encoding(UTF-8)';
5464
binmode STDERR, ':encoding(UTF-8)';
5565
@@ -68,10 +78,45 @@ sub read_file {
6878
6979
sub write_file {
7080
my ( $path, $content ) = @_;
71-
open my $fh, '>:encoding(UTF-8)', $path
72-
or die_tool("Could not write $path: $!");
73-
print {$fh} $content;
74-
close $fh;
81+
82+
# Atomic, same-directory replacement: readers only ever observe the
83+
# old or the new file, never a partial write.
84+
my $dir = dirname($path);
85+
my $mode;
86+
if ( -e $path ) {
87+
$mode = ( stat($path) )[2] & 07777;
88+
}
89+
else {
90+
my $mask = umask;
91+
umask $mask;
92+
$mode = 0666 & ~$mask;
93+
}
94+
95+
my ( $fh, $tmp ) = tempfile( '.tmp-XXXXXX', DIR => $dir, UNLINK => 0 );
96+
binmode $fh, ':encoding(UTF-8)';
97+
98+
if ( !print {$fh} $content ) {
99+
my $err = $!;
100+
close $fh;
101+
unlink $tmp;
102+
die_tool("Could not write $path: $err");
103+
}
104+
if ( !close $fh ) {
105+
my $err = $!;
106+
unlink $tmp;
107+
die_tool("Could not close $path: $err");
108+
}
109+
if ( defined $mode && !chmod $mode, $tmp ) {
110+
my $err = $!;
111+
unlink $tmp;
112+
die_tool("Could not set permissions on $path: $err");
113+
}
114+
if ( !rename $tmp, $path ) {
115+
my $err = $!;
116+
unlink $tmp;
117+
die_tool("Could not replace $path: $err");
118+
}
119+
return 1;
75120
}
76121
77122
sub xml_escape {
@@ -83,15 +128,38 @@ sub xml_escape {
83128
return $text;
84129
}
85130
86-
sub decode_html_quotes {
131+
sub decode_entities {
87132
my ($text) = @_;
88133
return '' unless defined $text;
89-
$text =~ s/&quot;/"/gi;
90-
$text =~ s/&#0*34;/"/g;
91-
$text =~ s/&#x0*22;/"/gi;
92-
$text =~ s/&apos;/'/gi;
93-
$text =~ s/&#0*39;/'/g;
94-
$text =~ s/&#x0*27;/'/gi;
134+
135+
# Decode the common named entities plus numeric references so that
136+
# HTML-sourced text can be safely re-encoded as XML without double
137+
# encoding. Unknown entities are left untouched.
138+
my %named = (
139+
amp => '&',
140+
lt => '<',
141+
gt => '>',
142+
quot => '"',
143+
apos => "'",
144+
nbsp => "\x{a0}",
145+
);
146+
147+
$text =~ s{&(\#[0-9]+|\#[xX][0-9A-Fa-f]+|[A-Za-z][A-Za-z0-9]*);}{
148+
my $e = $1;
149+
if ( $e =~ /^\#([0-9]+)$/ ) {
150+
$1 <= 0x10FFFF ? chr($1) : "&$e;";
151+
}
152+
elsif ( $e =~ /^\#[xX]([0-9A-Fa-f]+)$/ ) {
153+
hex($1) <= 0x10FFFF ? chr( hex $1 ) : "&$e;";
154+
}
155+
elsif ( exists $named{$e} ) {
156+
$named{$e};
157+
}
158+
else {
159+
"&$e;";
160+
}
161+
}ge;
162+
95163
return $text;
96164
}
97165
@@ -208,6 +276,7 @@ sub extract_metadata {
208276
my ($title) = $content_scope =~ /<h[12][^>]*>\s*(.*?)\s*<\/h[12]>/is;
209277
$title //= 'Untitled';
210278
$title = strip_tags_and_trim($title);
279+
$title = decode_entities($title);
211280

212281
my ($desc) = $html =~ /<meta[^>]*\bname\s*=\s*["']
213282
description["'][^>]*\bcontent
@@ -224,9 +293,8 @@ sub extract_metadata {
224293
($desc) = $content_scope =~ /<p[^>]*>\s*(.*?)\s*<\/p>/is;
225294
$desc //= '';
226295
}
227-
$desc = decode_html_quotes($desc);
228-
$desc = strip_tags_and_trim($desc);
229296
$desc = normalize_summary($desc);
297+
$desc = decode_entities($desc);
230298

231299
my ($iso) = $html =~ /<time[^>]*\sdatetime\s*=\s*"([^"]+)"/i;
232300
$iso //= ( $html =~ /<time[^>]*\sdatetime\s*=\s*'([^']+)'/i )[0];
@@ -255,7 +323,10 @@ sub collect_articles {
255323
wanted => sub {
256324
return unless -f $_;
257325
return unless /\.html$/i;
258-
return if $_ eq 'index.html';
326+
327+
# With no_chdir => 1, $_ is the full path, so match the
328+
# basename rather than comparing to 'index.html'.
329+
return if m{(?:^|/)index\.html$}i;
259330

260331
my $path = $File::Find::name;
261332
my $meta = extract_metadata($path);
@@ -273,6 +344,15 @@ sub collect_articles {
273344
return \@rows;
274345
}
275346

347+
sub abs_url {
348+
my ($path) = @_;
349+
return $path if !length $base_url;
350+
return $path if $path =~ m{^[a-z][a-z0-9+.-]*://}i;
351+
return "$base_url/" if $path eq '/';
352+
return $path if $path !~ m{^/};
353+
return "$base_url$path";
354+
}
355+
276356
sub rebuild_feed_file {
277357
my ( $feed_path, $lang, $rows ) = @_;
278358
my $xml = read_file($feed_path);
@@ -283,16 +363,18 @@ sub rebuild_feed_file {
283363
$channel_link = as_root_relative_url($channel_link);
284364
$channel_link = '/'
285365
unless length $channel_link;
366+
$channel_link = abs_url($channel_link);
286367
my ($channel_desc) = $xml =~ m{<description>(.*?)</description>}s;
287368
$channel_desc //= '';
288369
my $feed_name = basename($feed_path);
289-
my $feed_url = "/feeds/$feed_name";
370+
my $feed_url = abs_url("/feeds/$feed_name");
290371

291372
my $items = '';
292373
for my $r (@$rows) {
293374
next if $r->{lang} ne $lang;
294375
my $pub = format_pubdate( $r->{epoch} );
295-
my $article_url = "/articles/$r->{slug}.html";
376+
my $rel_url = "/articles/$r->{slug}.html";
377+
my $article_url = abs_url($rel_url);
296378
$items .= join '',
297379
" <item>\n",
298380
" <title>",
@@ -303,7 +385,7 @@ sub rebuild_feed_file {
303385
xml_escape( $r->{desc} ),
304386
"</description>\n",
305387
" <pubDate>$pub</pubDate>\n",
306-
" <guid" . " isPermaLink=\"false\">" . "$article_url</guid>\n",
388+
" <guid" . " isPermaLink=\"false\">" . "$rel_url</guid>\n",
307389
" </item>\n";
308390
}
309391

@@ -319,9 +401,9 @@ sub rebuild_feed_file {
319401
qq(<?xml version="1.0" encoding="UTF-8"?>\n),
320402
qq(<rss xmlns:atom="http://www.w3.org/) . qq(2005/Atom" version="2.0">\n),
321403
qq( <channel>\n),
322-
qq( <title>) . xml_escape($channel_title) . qq(</title>\n),
404+
qq( <title>$channel_title</title>\n),
323405
qq( <link>$channel_link</link>\n),
324-
qq( <description>) . xml_escape($channel_desc) . qq(</description>\n),
406+
qq( <description>$channel_desc</description>\n),
325407
qq( <lastBuildDate>$last_build) . qq(</lastBuildDate>\n),
326408
qq( <atom:link href="$feed_url")
327409
. qq( rel="self")

0 commit comments

Comments
 (0)