commit - 1396fea0b2bcd64a9ce0209971ed4f1eade8d28e
commit + 97996b0baa16c6ac08479bab8300a2fdb394ec0b
blob - 5ae953572dda98e3229e9c58710e2d760e76ee88
blob + 2387032d4f53c3f645b75e10787fb5df3f2c7e90
--- .gitignore
+++ .gitignore
-wp
+wop
doc/
site/
ports-dump.txt
blob - b2cffd8e3bd22a9389aa0b7af69c88328e6a4907
blob + 827fa4f66390322bb46986c2bf406c122ec34ddc
--- Makefile
+++ Makefile
PREFIX = /usr/local
MANDIR = ${PREFIX}/man
-BIN = wp
-SRC = wp.rs
-MAN = wp.1
+BIN = wop
+SRC = wop.rs
+MAN = wop.1
all: ${BIN}
blob - a8ebfe98bfe3795581d748c9128fdeb0e9810fe1
blob + a820156b4f671b8124aa33a28229dd8447750fbf
--- README.md
+++ README.md
-wp - where OpenBSD ports
+wop - where OpenBSD ports
========================
-wp generates a static HTML site for browsing OpenBSD ports.
+wop generates a static HTML site for browsing OpenBSD ports.
No JavaScript, works in lynx. Includes built-in CGI search.
Requirements
Building
--------
-Edit Makefile to match your local setup (wp is installed
+Edit Makefile to match your local setup (wop is installed
into the /usr/local/bin namespace by default).
Afterwards enter the following command to build and install
-wp (if necessary as root):
+wop (if necessary as root):
make
make install
Running
-------
- wp -p /usr/ports -o /var/www/htdocs/ports
+ wop -p /usr/ports -o /var/www/htdocs/ports
Options:
-p portsdir path to ports tree (default: /usr/ports)
Search
------
-wp has built-in CGI support. When the QUERY_STRING environment
-variable is set, wp acts as a CGI program: it reads INDEX.txt
+wop has built-in CGI support. When the QUERY_STRING environment
+variable is set, wop acts as a CGI program: it reads INDEX.txt
from the current directory and returns search results as HTML.
The search matches port names, descriptions, and maintainers.
-----------------------
Generate the site:
- wp -p /usr/ports -o /var/www/htdocs/ports
+ wop -p /usr/ports -o /var/www/htdocs/ports
Copy the binary and INDEX.txt into the chroot:
- cp /usr/local/bin/wp /var/www/cgi-bin/search.cgi
+ cp /usr/local/bin/wop /var/www/cgi-bin/search.cgi
cp /var/www/htdocs/ports/INDEX.txt /var/www/cgi-bin/INDEX.txt
OpenBSD does not support static binaries. Copy the required
blob - db80dd8ec83f1da43955e0925013d38a9b854218 (mode 644)
blob + /dev/null
--- wp.1
+++ /dev/null
-.\"
-.\" Copyright (c) 2024-2026 Murilo Ijanc' <murilo@ijanc.org>
-.\"
-.\" Permission to use, copy, modify, and/or distribute this software for any
-.\" purpose with or without fee is hereby granted, provided that the above
-.\" copyright notice and this permission notice appear in all copies.
-.\"
-.\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
-.\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
-.\" MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
-.\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
-.\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
-.\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
-.\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
-.\"
-.Dd $Mdocdate: April 6 2026 $
-.Dt WP 1
-.Os
-.Sh NAME
-.Nm wp
-.Nd where OpenBSD ports
-.Sh SYNOPSIS
-.Nm wp
-.Op Fl d Ar dump
-.Op Fl o Ar outdir
-.Op Fl p Ar portsdir
-.Sh DESCRIPTION
-.Nm
-generates a static HTML site for browsing
-.Ox
-ports.
-The generated pages contain no JavaScript and work in text browsers
-such as
-.Xr lynx 1 .
-.Pp
-When the
-.Ev QUERY_STRING
-environment variable is set,
-.Nm
-operates as a CGI program: it reads
-.Pa INDEX.txt
-from the current directory, searches for the query term, and writes
-HTML results to standard output.
-.Pp
-The options are as follows:
-.Bl -tag -width Ds
-.It Fl d Ar dump
-Load port data from a
-.Xr make 1
-.Cm dump-vars
-output file instead of parsing Makefiles directly.
-.It Fl o Ar outdir
-Write generated files to
-.Ar outdir .
-The default is
-.Pa ./site .
-.It Fl p Ar portsdir
-Path to the ports tree.
-The default is
-.Pa /usr/ports .
-.El
-.Sh OUTPUT
-.Nm
-generates the following files in
-.Ar outdir :
-.Bl -tag -width "categories/"
-.It Pa index.html
-Main page with search form, alphabetical index, and category listing.
-.It Pa categories/
-One HTML file per category listing all ports in that category.
-.It Pa ports/
-One HTML file per port with description, homepage, maintainer,
-dependencies, and sub-packages.
-.It Pa search/
-Alphabetical index pages from A to Z.
-.It Pa INDEX.txt
-Tab-separated index used by CGI search.
-Format: path, pkgname, comment, maintainer.
-.It Pa favicon.ico , Pa logo.png
-Static assets embedded in the binary.
-.El
-.Sh CGI
-To use
-.Nm
-as a CGI program with
-.Xr httpd 8 ,
-copy the binary and
-.Pa INDEX.txt
-into the chroot:
-.Bd -literal -offset indent
-cp /usr/local/bin/wp /var/www/cgi-bin/search.cgi
-cp /var/www/htdocs/ports/INDEX.txt /var/www/cgi-bin/
-.Ed
-.Pp
-Since
-.Ox
-does not support static binaries, the required shared libraries
-must be copied into the chroot:
-.Bd -literal -offset indent
-mkdir -p /var/www/usr/lib /var/www/usr/libexec
-cp /usr/lib/libc.so.* /var/www/usr/lib/
-cp /usr/lib/libm.so.* /var/www/usr/lib/
-cp /usr/lib/libpthread.so.* /var/www/usr/lib/
-cp /usr/lib/libc++abi.so.* /var/www/usr/lib/
-cp /usr/libexec/ld.so /var/www/usr/libexec/
-.Ed
-.Pp
-Configure
-.Xr httpd.conf 5 :
-.Bd -literal -offset indent
-server "ports.example.com" {
- listen on * port 80
- root "/htdocs/ports"
- location "/search.cgi" {
- fastcgi socket "/run/slowcgi.sock"
- root "/"
- }
-}
-.Ed
-.Pp
-Enable
-.Xr slowcgi 8 :
-.Bd -literal -offset indent
-# rcctl enable slowcgi
-# rcctl start slowcgi
-# rcctl reload httpd
-.Ed
-.Sh ENVIRONMENT
-.Bl -tag -width "QUERY_STRING"
-.It Ev QUERY_STRING
-When set,
-.Nm
-operates in CGI mode.
-The query parameter
-.Cm q
-is used as the search term.
-.El
-.Sh FILES
-.Bl -tag -width "/usr/ports"
-.It Pa /usr/ports
-Default ports tree location.
-.El
-.Sh EXIT STATUS
-.Ex -std wp
-.Sh EXAMPLES
-Generate a site from the ports tree:
-.Bd -literal -offset indent
-$ wp -p /usr/ports -o /var/www/htdocs/ports
-.Ed
-.Pp
-Generate a site from a dump-vars file:
-.Bd -literal -offset indent
-$ wp -d ports-dump.txt -p /usr/ports -o /var/www/htdocs/ports
-.Ed
-.Pp
-Test CGI search locally:
-.Bd -literal -offset indent
-$ cd /var/www/htdocs/ports
-$ QUERY_STRING="q=curl" /usr/local/bin/wp
-.Ed
-.Sh SEE ALSO
-.Xr httpd.conf 5 ,
-.Xr ports 7 ,
-.Xr httpd 8 ,
-.Xr slowcgi 8
-.Sh AUTHORS
-.An Murilo Ijanc' Aq Mt murilo@ijanc.org
blob - /dev/null
blob + c3408b284bc16e57dfacbb89ffdb29b765466a76 (mode 644)
--- /dev/null
+++ wop.1
+.\"
+.\" Copyright (c) 2024-2026 Murilo Ijanc' <murilo@ijanc.org>
+.\"
+.\" Permission to use, copy, modify, and/or distribute this software for any
+.\" purpose with or without fee is hereby granted, provided that the above
+.\" copyright notice and this permission notice appear in all copies.
+.\"
+.\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+.\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+.\" MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+.\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+.\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+.\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+.\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+.\"
+.Dd $Mdocdate: April 6 2026 $
+.Dt WOP 1
+.Os
+.Sh NAME
+.Nm wop
+.Nd where OpenBSD ports
+.Sh SYNOPSIS
+.Nm wop
+.Op Fl d Ar dump
+.Op Fl o Ar outdir
+.Op Fl p Ar portsdir
+.Sh DESCRIPTION
+.Nm
+generates a static HTML site for browsing
+.Ox
+ports.
+The generated pages contain no JavaScript and work in text browsers
+such as
+.Xr lynx 1 .
+.Pp
+When the
+.Ev QUERY_STRING
+environment variable is set,
+.Nm
+operates as a CGI program: it reads
+.Pa INDEX.txt
+from the current directory, searches for the query term, and writes
+HTML results to standard output.
+.Pp
+The options are as follows:
+.Bl -tag -width Ds
+.It Fl d Ar dump
+Load port data from a
+.Xr make 1
+.Cm dump-vars
+output file instead of parsing Makefiles directly.
+.It Fl o Ar outdir
+Write generated files to
+.Ar outdir .
+The default is
+.Pa ./site .
+.It Fl p Ar portsdir
+Path to the ports tree.
+The default is
+.Pa /usr/ports .
+.El
+.Sh OUTPUT
+.Nm
+generates the following files in
+.Ar outdir :
+.Bl -tag -width "categories/"
+.It Pa index.html
+Main page with search form, alphabetical index, and category listing.
+.It Pa categories/
+One HTML file per category listing all ports in that category.
+.It Pa ports/
+One HTML file per port with description, homepage, maintainer,
+dependencies, and sub-packages.
+.It Pa search/
+Alphabetical index pages from A to Z.
+.It Pa INDEX.txt
+Tab-separated index used by CGI search.
+Format: path, pkgname, comment, maintainer.
+.It Pa favicon.ico , Pa logo.png
+Static assets embedded in the binary.
+.El
+.Sh CGI
+To use
+.Nm
+as a CGI program with
+.Xr httpd 8 ,
+copy the binary and
+.Pa INDEX.txt
+into the chroot:
+.Bd -literal -offset indent
+cp /usr/local/bin/wop /var/www/cgi-bin/search.cgi
+cp /var/www/htdocs/ports/INDEX.txt /var/www/cgi-bin/
+.Ed
+.Pp
+Since
+.Ox
+does not support static binaries, the required shared libraries
+must be copied into the chroot:
+.Bd -literal -offset indent
+mkdir -p /var/www/usr/lib /var/www/usr/libexec
+cp /usr/lib/libc.so.* /var/www/usr/lib/
+cp /usr/lib/libm.so.* /var/www/usr/lib/
+cp /usr/lib/libpthread.so.* /var/www/usr/lib/
+cp /usr/lib/libc++abi.so.* /var/www/usr/lib/
+cp /usr/libexec/ld.so /var/www/usr/libexec/
+.Ed
+.Pp
+Configure
+.Xr httpd.conf 5 :
+.Bd -literal -offset indent
+server "ports.example.com" {
+ listen on * port 80
+ root "/htdocs/ports"
+ location "/search.cgi" {
+ fastcgi socket "/run/slowcgi.sock"
+ root "/"
+ }
+}
+.Ed
+.Pp
+Enable
+.Xr slowcgi 8 :
+.Bd -literal -offset indent
+# rcctl enable slowcgi
+# rcctl start slowcgi
+# rcctl reload httpd
+.Ed
+.Sh ENVIRONMENT
+.Bl -tag -width "QUERY_STRING"
+.It Ev QUERY_STRING
+When set,
+.Nm
+operates in CGI mode.
+The query parameter
+.Cm q
+is used as the search term.
+.El
+.Sh FILES
+.Bl -tag -width "/usr/ports"
+.It Pa /usr/ports
+Default ports tree location.
+.El
+.Sh EXIT STATUS
+.Ex -std wp
+.Sh EXAMPLES
+Generate a site from the ports tree:
+.Bd -literal -offset indent
+$ wop -p /usr/ports -o /var/www/htdocs/ports
+.Ed
+.Pp
+Generate a site from a dump-vars file:
+.Bd -literal -offset indent
+$ wop -d ports-dump.txt -p /usr/ports -o /var/www/htdocs/ports
+.Ed
+.Pp
+Test CGI search locally:
+.Bd -literal -offset indent
+$ cd /var/www/htdocs/ports
+$ QUERY_STRING="q=curl" /usr/local/bin/wop
+.Ed
+.Sh SEE ALSO
+.Xr httpd.conf 5 ,
+.Xr ports 7 ,
+.Xr httpd 8 ,
+.Xr slowcgi 8
+.Sh AUTHORS
+.An Murilo Ijanc' Aq Mt murilo@ijanc.org
blob - a91c6ebf9951e77e06952f816d8ae6ce25b19524 (mode 644)
blob + /dev/null
--- wp.rs
+++ /dev/null
-// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et :
-//
-// Copyright (c) 2024-2026 Murilo Ijanc' <murilo@ijanc.org>
-//
-// Permission to use, copy, modify, and/or distribute this software for any
-// purpose with or without fee is hereby granted, provided that the above
-// copyright notice and this permission notice appear in all copies.
-//
-// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
-// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
-// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
-// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
-// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
-// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
-// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
-//
-use std::{
- collections::HashMap,
- fs,
- io::Write,
- path::{Path, PathBuf},
-};
-
-#[cfg(target_os = "openbsd")]
-mod sandbox {
- use std::ffi::CString;
- use std::os::raw::c_char;
- use std::path::Path;
-
- extern "C" {
- fn pledge(promises: *const c_char, execpromises: *const c_char) -> i32;
- fn unveil(path: *const c_char, permissions: *const c_char) -> i32;
- }
-
- pub fn do_unveil(path: &Path, perms: &str) {
- let p = CString::new(path.to_str().expect("unveil: invalid path"))
- .expect("unveil: nul in path");
- let f = CString::new(perms).expect("unveil: nul in perms");
- if unsafe { unveil(p.as_ptr(), f.as_ptr()) } == -1 {
- let e = std::io::Error::last_os_error();
- super::err(&format!("unveil {}: {}", path.display(), e));
- }
- }
-
- pub fn lock_unveil() {
- if unsafe { unveil(std::ptr::null(), std::ptr::null()) } == -1 {
- let e = std::io::Error::last_os_error();
- super::err(&format!("unveil lock: {}", e));
- }
- }
-
- pub fn do_pledge(promises: &str) {
- let p = CString::new(promises).expect("pledge: nul in promises");
- if unsafe { pledge(p.as_ptr(), std::ptr::null()) } == -1 {
- let e = std::io::Error::last_os_error();
- super::err(&format!("pledge: {}", e));
- }
- }
-}
-
-const VERSION: &str = "0.1.0";
-const NAME: &str = "wp";
-const FAVICON: &[u8] = include_bytes!("assets/favicon.ico");
-const LOGO: &[u8] = include_bytes!("assets/logo.png");
-const CSS: &str = "\
-body{font-family:monospace;max-width:72em;margin:0 auto;padding:1em;\
-color:#222;background:#fff}\
-h1,h2{margin:.5em 0}\
-table{border-collapse:collapse;width:100%}\
-th,td{text-align:left;padding:.2em .5em;border-bottom:1px solid #ccc}\
-th{border-bottom:2px solid #333}\
-a{color:#00e}a:visited{color:#551a8b}\
-pre{overflow-x:auto}\
-footer{color:#666;font-size:.9em;margin-top:1em}\
-img.logo{width:4em;height:4em;vertical-align:middle;margin-right:.5em}\
-@media(prefers-color-scheme:dark){\
-body{color:#ddd;background:#1a1a1a}\
-th{border-bottom-color:#888}\
-th,td{border-bottom-color:#444}\
-a{color:#6bf}a:visited{color:#c9f}\
-footer{color:#888}\
-hr{border-color:#444}}\
-";
-
-struct Port {
- name: String,
- category: String,
- comment: String,
- descr: String,
- homepage: String,
- maintainer: String,
- categories: Vec<String>,
- distname: String,
- pkgname: String,
- lib_depends: Vec<String>,
- run_depends: Vec<String>,
- build_depends: Vec<String>,
- multi_packages: Vec<String>,
- sub_comments: HashMap<String, String>,
-}
-
-fn apply_modifier(val: &str, modifier: &str) -> String {
- if modifier == "R" {
- // :R - root (strip extension)
- val.rsplit_once('.')
- .map(|(r, _)| r.to_string())
- .unwrap_or_else(|| val.to_string())
- } else if modifier == "E" {
- // :E - extension
- val.rsplit_once('.')
- .map(|(_, e)| e.to_string())
- .unwrap_or_default()
- } else if modifier == "L" {
- // :L - lowercase
- val.to_lowercase()
- } else if modifier == "U" {
- // :U - uppercase
- val.to_uppercase()
- } else if let Some(rest) = modifier.strip_prefix("S/") {
- // :S/old/new/ - substitute first
- if let Some((old, tail)) = rest.split_once('/') {
- let new = tail.trim_end_matches('/');
- val.replacen(old, new, 1)
- } else {
- val.to_string()
- }
- } else if let Some(rest) = modifier.strip_prefix("C/") {
- // :C/pat/rep/ - substitute all (simplified, no regex)
- if let Some((old, tail)) = rest.split_once('/') {
- let new = tail.trim_end_matches('/');
- val.replace(old, new)
- } else {
- val.to_string()
- }
- } else {
- val.to_string()
- }
-}
-
-fn expand_vars(s: &str, vars: &HashMap<String, String>) -> String {
- let mut result = s.to_string();
- for _ in 0..8 {
- let prev = result.clone();
- let mut out = String::new();
- let mut chars = result.as_str();
-
- while let Some(pos) = chars.find("${") {
- out.push_str(&chars[..pos]);
- let rest = &chars[pos + 2..];
- let Some(end) = rest.find('}') else {
- out.push_str(&chars[pos..]);
- chars = "";
- break;
- };
- let expr = &rest[..end];
- let (varname, modifiers) =
- if let Some((v, m)) = expr.split_once(':') {
- (v, Some(m))
- } else {
- (expr, None)
- };
-
- if let Some(val) = vars.get(varname) {
- let val = if let Some(mods) = modifiers {
- if val.contains("${") {
- out.push_str(&format!("${{{}}}", expr));
- chars = &rest[end + 1..];
- continue;
- }
- let mut v = val.clone();
- for m in mods.split(':') {
- v = apply_modifier(&v, m);
- }
- v
- } else {
- val.clone()
- };
- out.push_str(&val);
- } else {
- out.push_str(&format!("${{{}}}", expr));
- }
- chars = &rest[end + 1..];
- }
- out.push_str(chars);
- result = out;
-
- if result == prev {
- break;
- }
- }
- result
-}
-
-type ModCache = HashMap<String, Vec<(String, String)>>;
-
-fn load_module(
- module: &str,
- portsdir: &Path,
- cache: &mut ModCache,
-) -> Option<()> {
- if cache.contains_key(module) {
- return Some(());
- }
-
- let modname = module.split('/').next_back().unwrap_or(module);
- let mk_path = portsdir.join(module).join(format!("{}.port.mk", modname));
- let mk_path = if mk_path.exists() {
- mk_path
- } else {
- let alt = portsdir
- .join("infrastructure/mk")
- .join(format!("{}.port.mk", modname));
- if alt.exists() {
- alt
- } else {
- cache.insert(module.to_string(), Vec::new());
- return None;
- }
- };
-
- let text = fs::read_to_string(&mk_path).ok()?;
- let mut modvars = Vec::new();
-
- for line in text.lines() {
- let line = line.trim();
- if line.starts_with('#')
- || line.starts_with('.')
- || !line.contains("MOD")
- {
- continue;
- }
- if let Some(pos) = line.find("?=") {
- let k = line[..pos].trim();
- if k.starts_with("MOD") {
- let v = line[pos + 2..].trim();
- modvars.push((k.to_string(), v.to_string()));
- }
- } else if let Some(pos) = line.find('=') {
- if line[..pos].ends_with('+') {
- continue;
- }
- let k = line[..pos].trim();
- if k.starts_with("MOD") {
- let v = line[pos + 1..].trim();
- let v = v.trim_end_matches('\\').trim();
- modvars.push((k.to_string(), v.to_string()));
- }
- }
- }
-
- cache.insert(module.to_string(), modvars);
- Some(())
-}
-
-fn resolve_modules(
- vars: &mut HashMap<String, String>,
- portsdir: &Path,
- cache: &mut ModCache,
-) {
- let modules = vars.get("MODULES").cloned().unwrap_or_default();
-
- for module in modules.split_whitespace() {
- if module.contains("..") {
- continue;
- }
- load_module(module, portsdir, cache);
- if let Some(modvars) = cache.get(module) {
- for (k, v) in modvars {
- vars.entry(k.clone()).or_insert_with(|| v.clone());
- }
- }
- }
-}
-
-fn parse_makefile(path: &Path) -> Option<HashMap<String, String>> {
- let text = fs::read_to_string(path).ok()?;
- let mut vars: HashMap<String, String> = HashMap::new();
- let mut continued = String::new();
- let mut cont_key = String::new();
-
- for line in text.lines() {
- let line = line.trim_end();
-
- if !continued.is_empty() {
- let part = line.trim_start();
- if part.ends_with('\\') {
- continued.push(' ');
- continued.push_str(part.trim_end_matches('\\').trim());
- } else {
- continued.push(' ');
- continued.push_str(part);
- vars.insert(cont_key.clone(), continued.trim().to_string());
- continued.clear();
- cont_key.clear();
- }
- continue;
- }
-
- if line.starts_with('#')
- || line.starts_with('.')
- || line.starts_with('\t')
- {
- continue;
- }
-
- let (key, val) = if let Some(pos) = line.find("+=") {
- let k = line[..pos].trim();
- let v = line[pos + 2..].trim();
- let prev = vars.get(k).cloned().unwrap_or_default();
- (
- k.to_string(),
- format!("{} {}", prev, v.trim_end_matches('\\').trim()),
- )
- } else if let Some(pos) = line.find("?=") {
- let k = line[..pos].trim();
- if vars.contains_key(k) {
- continue;
- }
- let v = line[pos + 2..].trim();
- (k.to_string(), v.trim_end_matches('\\').trim().to_string())
- } else if let Some(pos) = line.find('=') {
- let k = line[..pos].trim();
- let v = line[pos + 1..].trim();
- (k.to_string(), v.trim_end_matches('\\').trim().to_string())
- } else {
- continue;
- };
-
- if line.ends_with('\\') {
- continued = val;
- cont_key = key;
- } else {
- vars.insert(key, val);
- }
- }
-
- if !cont_key.is_empty() {
- vars.insert(cont_key, continued.trim().to_string());
- }
-
- Some(vars)
-}
-
-fn safe_name(s: &str) -> bool {
- !s.is_empty()
- && !s.contains('/')
- && !s.contains('\0')
- && s != "."
- && s != ".."
-}
-
-fn port_from_makefile(
- category: &str,
- portname: &str,
- portdir: &Path,
- portsdir: &Path,
- modcache: &mut ModCache,
-) -> Option<Port> {
- if !safe_name(category) || !safe_name(portname) {
- return None;
- }
- let mf = portdir.join("Makefile");
- let mut vars = parse_makefile(&mf)?;
-
- resolve_modules(&mut vars, portsdir, modcache);
-
- let get = |k: &str| -> String {
- let raw = vars.get(k).cloned().unwrap_or_default();
- expand_vars(&raw, &vars)
- };
-
- let mut comment = get("COMMENT");
- if comment.is_empty() {
- comment = get("COMMENT-main");
- }
- if comment.is_empty() {
- return None;
- }
-
- let descr_path = portdir.join("pkg").join("DESCR");
- let descr = fs::read_to_string(&descr_path).unwrap_or_default();
-
- let categories: Vec<String> = get("CATEGORIES")
- .split_whitespace()
- .map(String::from)
- .collect();
-
- let split_deps = |val: &str| -> Vec<String> {
- val.split_whitespace()
- .map(|s| expand_vars(s, &vars))
- .filter(|s| !s.is_empty() && !s.contains("${"))
- .collect()
- };
-
- let multi_packages: Vec<String> = get("MULTI_PACKAGES")
- .split_whitespace()
- .map(String::from)
- .collect();
-
- let mut sub_comments = HashMap::new();
- for sub in &multi_packages {
- let key = format!("COMMENT{}", sub);
- if let Some(val) = vars.get(&key) {
- sub_comments.insert(sub.clone(), expand_vars(val, &vars));
- }
- }
-
- Some(Port {
- name: portname.to_string(),
- category: category.to_string(),
- comment,
- descr,
- homepage: get("HOMEPAGE"),
- maintainer: get("MAINTAINER"),
- categories,
- distname: get("DISTNAME"),
- pkgname: get("PKGNAME"),
- lib_depends: split_deps(&get("LIB_DEPENDS")),
- run_depends: split_deps(&get("RUN_DEPENDS")),
- build_depends: split_deps(&get("BUILD_DEPENDS")),
- multi_packages,
- sub_comments,
- })
-}
-
-fn port_from_vars(
- path: &str,
- vars: &HashMap<String, String>,
- portsdir: Option<&Path>,
-) -> Option<Port> {
- let (category, name) = path.split_once('/')?;
- if !safe_name(category) || !safe_name(name) {
- return None;
- }
- let comment = vars.get("COMMENT").filter(|s| !s.is_empty())?;
-
- let descr = if let Some(pd) = portsdir {
- let descr_path = pd.join(path).join("pkg").join("DESCR");
- fs::read_to_string(&descr_path).unwrap_or_default()
- } else {
- String::new()
- };
-
- let split = |k: &str| -> Vec<String> {
- vars.get(k)
- .map(|v| v.split_whitespace().map(String::from).collect())
- .unwrap_or_default()
- };
-
- Some(Port {
- name: name.to_string(),
- category: category.to_string(),
- comment: comment.clone(),
- descr,
- homepage: vars.get("HOMEPAGE").cloned().unwrap_or_default(),
- maintainer: vars.get("MAINTAINER").cloned().unwrap_or_default(),
- categories: split("CATEGORIES"),
- distname: String::new(),
- pkgname: vars.get("FULLPKGNAME").cloned().unwrap_or_default(),
- lib_depends: split("LIB_DEPENDS"),
- run_depends: split("RUN_DEPENDS"),
- build_depends: split("BUILD_DEPENDS"),
- multi_packages: split("MULTI_PACKAGES"),
- sub_comments: HashMap::new(),
- })
-}
-
-fn load_dump(path: &Path, portsdir: Option<&Path>) -> Vec<Port> {
- let text = fs::read_to_string(path).unwrap_or_else(|e| errio(path, &e));
-
- let mut by_port: HashMap<String, HashMap<String, String>> = HashMap::new();
-
- for line in text.lines() {
- // format: category/port.VARIABLE=value
- // find the first '=' then look backwards for the '.'
- let Some(eq_pos) = line.find('=') else {
- continue;
- };
- let prefix = &line[..eq_pos];
- let Some(dot) = prefix.rfind('.') else {
- continue;
- };
- let portpath = &line[..dot];
- let var = &line[dot + 1..eq_pos];
- let val = &line[eq_pos + 1..];
- by_port
- .entry(portpath.to_string())
- .or_default()
- .insert(var.to_string(), val.to_string());
- }
-
- let mut ports = Vec::new();
- let mut paths: Vec<_> = by_port.keys().cloned().collect();
- paths.sort();
-
- for path in paths {
- let vars = &by_port[&path];
- if let Some(port) = port_from_vars(&path, vars, portsdir) {
- ports.push(port);
- }
- }
-
- ports
-}
-
-fn walk_ports(portsdir: &Path) -> Vec<Port> {
- let mut ports = Vec::new();
- let mut modcache = ModCache::new();
- let skip = [
- "infrastructure",
- "tests",
- ".git",
- ".gitignore",
- ".cvsignore",
- ];
-
- let mut cats: Vec<_> = fs::read_dir(portsdir)
- .unwrap_or_else(|e| errio(portsdir, &e))
- .filter_map(|e| e.ok())
- .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
- .filter(|e| {
- let n = e.file_name();
- !skip.contains(&n.to_str().unwrap_or(""))
- })
- .collect();
- cats.sort_by_key(|e| e.file_name());
-
- for cat in cats {
- let catname = cat.file_name().to_string_lossy().to_string();
- let mut entries: Vec<_> = match fs::read_dir(cat.path()) {
- Ok(rd) => rd
- .filter_map(|e| e.ok())
- .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
- .collect(),
- Err(_) => continue,
- };
- entries.sort_by_key(|e| e.file_name());
-
- for entry in entries {
- let portname = entry.file_name().to_string_lossy().to_string();
- if let Some(port) = port_from_makefile(
- &catname,
- &portname,
- &entry.path(),
- portsdir,
- &mut modcache,
- ) {
- ports.push(port);
- }
- }
- }
-
- ports
-}
-
-fn head(title: &str, depth: usize, extra: &str) -> String {
- let rel = "../".repeat(depth);
- format!(
- "<!DOCTYPE html>\n<html>\n<head>\
- <meta charset=\"utf-8\">\
- <meta name=\"viewport\" \
- content=\"width=device-width,initial-scale=1\">\
- <title>{}</title>\
- <link rel=\"icon\" href=\"{}favicon.ico\">\
- {}\
- <style>{}</style></head>\n<body>\n",
- html_escape(title),
- rel,
- extra,
- CSS
- )
-}
-
-fn port_meta(p: &Port, depth: usize) -> String {
- let rel = "../".repeat(depth);
- let keywords: Vec<&str> = p.categories.iter().map(|s| s.as_str()).collect();
- format!(
- "<meta name=\"description\" content=\"{}\">\
- <meta name=\"author\" content=\"{}\">\
- <meta name=\"keywords\" content=\"{}\">\
- <meta property=\"og:title\" content=\"{}\">\
- <meta property=\"og:description\" content=\"{}\">\
- <meta property=\"og:type\" content=\"website\">\
- <meta property=\"og:image\" content=\"{}logo.png\">",
- html_escape(&p.comment),
- html_escape(&p.maintainer),
- html_escape(&keywords.join(", ")),
- html_escape(&format!("{}/{}", p.category, p.name)),
- html_escape(&p.comment),
- rel,
- )
-}
-
-fn html_escape(s: &str) -> String {
- if !s.contains(&['&', '<', '>', '"', '\''][..]) {
- return s.to_string();
- }
- let mut out = String::with_capacity(s.len() + 8);
- for c in s.chars() {
- match c {
- '&' => out.push_str("&"),
- '<' => out.push_str("<"),
- '>' => out.push_str(">"),
- '"' => out.push_str("""),
- '\'' => out.push_str("'"),
- _ => out.push(c),
- }
- }
- out
-}
-
-fn local_date() -> String {
- use std::os::raw::{c_int, c_long};
-
- // struct tm layout per POSIX, with BSD extensions.
- // over-sized _pad absorbs any platform-specific trailing
- // fields so localtime_r never writes out of bounds.
- #[repr(C)]
- struct Tm {
- tm_sec: c_int,
- tm_min: c_int,
- tm_hour: c_int,
- tm_mday: c_int,
- tm_mon: c_int,
- tm_year: c_int,
- tm_wday: c_int,
- tm_yday: c_int,
- tm_isdst: c_int,
- _pad: [u8; 64],
- }
-
- extern "C" {
- fn time(t: *mut c_long) -> c_long;
- fn localtime_r(t: *const c_long, result: *mut Tm) -> *mut Tm;
- }
-
- unsafe {
- let mut t: c_long = 0;
- time(&mut t);
- let mut tm = std::mem::zeroed::<Tm>();
- let ret = localtime_r(&t, &mut tm);
- if ret.is_null() {
- return String::from("unknown");
- }
- format!(
- "{:04}-{:02}-{:02}",
- tm.tm_year + 1900,
- tm.tm_mon + 1,
- tm.tm_mday
- )
- }
-}
-
-fn footer(total: usize) -> String {
- format!(
- "<hr>\n<footer>{} {} | {} ports | {}</footer>\n",
- NAME,
- VERSION,
- total,
- local_date()
- )
-}
-
-fn dep_link(dep: &str) -> String {
- // dependency format: "category/port" or "pkg:category/port"
- // or "category/port,subpkg" or "category/port>=version"
- let dep = dep.split(':').next_back().unwrap_or(dep);
- let path = dep.split(['>', '<', '=']).next().unwrap_or(dep);
- let path = path.trim_end_matches(',');
-
- if let Some((cat, port)) = path.split_once('/') {
- let port = port.split(',').next().unwrap_or(port);
- format!(
- "<a href=\"../{}/{}.html\">{}</a>",
- html_escape(cat),
- html_escape(port),
- html_escape(dep)
- )
- } else {
- html_escape(dep)
- }
-}
-
-fn write_file(path: &Path, content: &str) {
- let mut f = fs::File::create(path).unwrap_or_else(|e| errio(path, &e));
- if let Err(e) = f.write_all(content.as_bytes()) {
- errio(path, &e);
- }
-}
-
-fn copy_assets(outdir: &Path) {
- let fav = outdir.join("favicon.ico");
- if let Err(e) = fs::write(&fav, FAVICON) {
- errio(&fav, &e);
- }
- let logo = outdir.join("logo.png");
- if let Err(e) = fs::write(&logo, LOGO) {
- errio(&logo, &e);
- }
-}
-
-fn write_index(ports: &[Port], outdir: &Path, ft: &str) {
- let mut cats: HashMap<&str, usize> = HashMap::new();
- for p in ports {
- *cats.entry(&p.category).or_default() += 1;
- }
- let mut sorted: Vec<_> = cats.into_iter().collect();
- sorted.sort_by_key(|&(k, _)| k);
-
- let mut html = head("OpenBSD Ports", 0, "");
- html.push_str(
- "<h1><img class=\"logo\" src=\"logo.png\" \
- alt=\"\">OpenBSD Ports</h1>\n",
- );
-
- html.push_str("<!-- SEARCH -->\n");
-
- html.push_str("<h2>Alphabetical Index</h2>\n<p>\n");
- for c in b'a'..=b'z' {
- let c = c as char;
- html.push_str(&format!(
- "<a href=\"search/{}.html\">{}</a> \n",
- c,
- c.to_ascii_uppercase()
- ));
- }
- html.push_str("</p>\n");
-
- html.push_str("<h2>Categories</h2>\n<ul>\n");
- for (cat, count) in &sorted {
- html.push_str(&format!(
- "<li><a href=\"categories/{}.html\">{}</a> \
- ({})</li>\n",
- cat, cat, count
- ));
- }
- html.push_str("</ul>\n");
-
- html.push_str(ft);
- html.push_str("</body>\n</html>\n");
-
- write_file(&outdir.join("index.html"), &html);
-}
-
-fn write_categories(ports: &[Port], outdir: &Path, ft: &str) {
- let mut by_cat: HashMap<&str, Vec<&Port>> = HashMap::new();
- for p in ports {
- by_cat.entry(&p.category).or_default().push(p);
- }
-
- let catdir = outdir.join("categories");
-
- for (cat, mut cat_ports) in by_cat {
- cat_ports.sort_by(|a, b| a.name.cmp(&b.name));
-
- let mut html = head(&format!("{} - OpenBSD Ports", cat), 1, "");
- html.push_str(&format!(
- "<h1>{}</h1>\n\
- <p><a href=\"../index.html\">Index</a></p>\n\
- <table>\n<tr><th>Port</th><th>Description</th>\
- </tr>\n",
- cat
- ));
-
- for p in &cat_ports {
- html.push_str(&format!(
- "<tr><td><a href=\"../ports/{}/{}.html\">\
- {}</a></td><td>{}</td></tr>\n",
- html_escape(&p.category),
- html_escape(&p.name),
- html_escape(&p.name),
- html_escape(&p.comment)
- ));
- }
-
- html.push_str("</table>\n");
- html.push_str(ft);
- html.push_str("</body>\n</html>\n");
-
- write_file(&catdir.join(format!("{}.html", cat)), &html);
- }
-}
-
-fn write_port_pages(ports: &[Port], outdir: &Path, ft: &str) {
- for p in ports {
- let meta = port_meta(p, 2);
- let mut html = head(
- &format!("{}/{} - OpenBSD Ports", &p.category, &p.name),
- 2,
- &meta,
- );
-
- let display_name = if !p.pkgname.is_empty() && !p.pkgname.contains("${")
- {
- &p.pkgname
- } else if !p.distname.is_empty() && !p.distname.contains("${") {
- &p.distname
- } else {
- &p.name
- };
- html.push_str(&format!("<h1>{}</h1>\n", html_escape(display_name)));
-
- html.push_str(&format!("<p>{}</p>\n", html_escape(&p.comment)));
-
- html.push_str(&format!(
- "<p><a href=\"../../index.html\">Index</a> | \
- <a href=\"../../categories/{}.html\">{}</a></p>\n",
- html_escape(&p.category),
- html_escape(&p.category)
- ));
-
- if !p.descr.is_empty() {
- html.push_str(&format!(
- "<h2>Description</h2>\n<pre>{}</pre>\n",
- html_escape(&p.descr)
- ));
- }
-
- if !p.homepage.is_empty()
- && (p.homepage.starts_with("https://")
- || p.homepage.starts_with("http://"))
- {
- html.push_str(&format!(
- "<p>Homepage: <a href=\"{}\">{}</a></p>\n",
- html_escape(&p.homepage),
- html_escape(&p.homepage)
- ));
- }
-
- if !p.maintainer.is_empty() {
- html.push_str(&format!(
- "<p>Maintainer: {}</p>\n",
- html_escape(&p.maintainer)
- ));
- }
-
- if p.categories.len() > 1 {
- html.push_str("<p>Categories: ");
- for (i, cat) in p.categories.iter().enumerate() {
- if i > 0 {
- html.push_str(", ");
- }
- html.push_str(&format!(
- "<a href=\"../../categories/{}.html\">\
- {}</a>",
- html_escape(cat),
- html_escape(cat)
- ));
- }
- html.push_str("</p>\n");
- }
-
- if !p.multi_packages.is_empty() {
- html.push_str("<h2>Sub-packages</h2>\n<ul>\n");
- for sub in &p.multi_packages {
- let comment =
- p.sub_comments.get(sub).map(|s| s.as_str()).unwrap_or("");
- html.push_str(&format!(
- "<li>{} - {}</li>\n",
- html_escape(sub),
- html_escape(comment)
- ));
- }
- html.push_str("</ul>\n");
- }
-
- let dep_section = |title: &str, deps: &[String]| -> String {
- if deps.is_empty() {
- return String::new();
- }
- let mut s = format!("<h2>{}</h2>\n<ul>\n", title);
- for d in deps {
- s.push_str(&format!("<li>{}</li>\n", dep_link(d)));
- }
- s.push_str("</ul>\n");
- s
- };
-
- html.push_str(&dep_section("Library Dependencies", &p.lib_depends));
- html.push_str(&dep_section("Run Dependencies", &p.run_depends));
- html.push_str(&dep_section("Build Dependencies", &p.build_depends));
-
- html.push_str(ft);
- html.push_str("</body>\n</html>\n");
-
- write_file(
- &outdir
- .join("ports")
- .join(&p.category)
- .join(format!("{}.html", p.name)),
- &html,
- );
- }
-}
-
-fn write_alpha(ports: &[Port], outdir: &Path, ft: &str) {
- let searchdir = outdir.join("search");
-
- for c in b'a'..=b'z' {
- let c = c as char;
- let mut matching: Vec<&Port> = ports
- .iter()
- .filter(|p| {
- p.name
- .chars()
- .next()
- .map(|ch| ch.to_ascii_lowercase() == c)
- .unwrap_or(false)
- })
- .collect();
- matching.sort_by(|a, b| a.name.cmp(&b.name));
-
- let mut html = head(
- &format!("{} - OpenBSD Ports", c.to_ascii_uppercase()),
- 1,
- "",
- );
- html.push_str(&format!(
- "<h1>Ports: {}</h1>\n\
- <p><a href=\"../index.html\">Index</a></p>\n\
- <p>\n",
- c.to_ascii_uppercase()
- ));
-
- for lc in b'a'..=b'z' {
- let lc = lc as char;
- if lc == c {
- html.push_str(&format!(
- "<b>{}</b> \n",
- lc.to_ascii_uppercase()
- ));
- } else {
- html.push_str(&format!(
- "<a href=\"{}.html\">{}</a> \n",
- lc,
- lc.to_ascii_uppercase()
- ));
- }
- }
- html.push_str("</p>\n");
-
- html.push_str(
- "<table>\n<tr><th>Port</th><th>Category</th>\
- <th>Description</th></tr>\n",
- );
- for p in &matching {
- html.push_str(&format!(
- "<tr><td><a href=\"../ports/{}/{}.html\">\
- {}</a></td><td><a href=\"../categories/\
- {}.html\">{}</a></td><td>{}</td></tr>\n",
- html_escape(&p.category),
- html_escape(&p.name),
- html_escape(&p.name),
- html_escape(&p.category),
- html_escape(&p.category),
- html_escape(&p.comment)
- ));
- }
-
- html.push_str("</table>\n");
- html.push_str(ft);
- html.push_str("</body>\n</html>\n");
-
- write_file(&searchdir.join(format!("{}.html", c)), &html);
- }
-}
-
-fn sanitize_field(s: &str) -> String {
- s.replace(['\t', '\n'], " ").replace('\r', "")
-}
-
-fn write_index_txt(ports: &[Port], outdir: &Path) {
- let mut out = String::new();
- for p in ports {
- // format: category/name\tpkgname\tcomment\tmaintainer
- out.push_str(&p.category);
- out.push('/');
- out.push_str(&p.name);
- out.push('\t');
- out.push_str(&sanitize_field(&p.pkgname));
- out.push('\t');
- out.push_str(&sanitize_field(&p.comment));
- out.push('\t');
- out.push_str(&sanitize_field(&p.maintainer));
- out.push('\n');
- }
- write_file(&outdir.join("INDEX.txt"), &out);
-}
-
-fn url_decode(s: &str) -> String {
- let mut out = Vec::with_capacity(s.len());
- let mut bytes = s.as_bytes().iter();
- while let Some(&b) = bytes.next() {
- if b == b'%' {
- let hi = bytes.next().copied().unwrap_or(0);
- let lo = bytes.next().copied().unwrap_or(0);
- let hex = [hi, lo];
- if let Ok(s) = std::str::from_utf8(&hex) {
- if let Ok(n) = u8::from_str_radix(s, 16) {
- if n != 0 {
- out.push(n);
- }
- continue;
- }
- }
- out.push(b);
- } else if b == b'+' {
- out.push(b' ');
- } else {
- out.push(b);
- }
- }
- String::from_utf8_lossy(&out).into_owned()
-}
-
-fn cgi_search(index_path: &Path) {
- let mut qs = std::env::var("QUERY_STRING").unwrap_or_default();
- qs.truncate(1024);
- let mut query = qs
- .split('&')
- .find_map(|p| p.strip_prefix("q=").map(url_decode))
- .unwrap_or_default()
- .to_lowercase();
- query.truncate(128);
-
- print!("Content-Type: text/html\r\n\r\n");
-
- let mut html = head("Search - OpenBSD Ports", 0, "");
- html.push_str("<h1>Search Results</h1>\n");
- html.push_str(
- "<form action=\"search.cgi\" method=\"get\">\n\
- <input type=\"text\" name=\"q\" size=\"40\"",
- );
- if !query.is_empty() {
- html.push_str(&format!(" value=\"{}\"", html_escape(&query)));
- }
- html.push_str(
- ">\n<input type=\"submit\" value=\"Search\">\n\
- </form>\n",
- );
- html.push_str("<p><a href=\"/index.html\">Index</a></p>\n");
-
- if query.is_empty() {
- html.push_str("<p>Enter a search term.</p>\n");
- html.push_str("</body>\n</html>\n");
- print!("{}", html);
- return;
- }
-
- let text = fs::read_to_string(index_path).unwrap_or_default();
-
- html.push_str(
- "<table>\n<tr><th>Port</th><th>Description</th>\
- <th>Maintainer</th></tr>\n",
- );
-
- let mut count = 0;
- for line in text.lines() {
- let lower = line.to_lowercase();
- if !lower.contains(&query) {
- continue;
- }
- let fields: Vec<&str> = line.splitn(4, '\t').collect();
- if fields.len() < 3 {
- continue;
- }
- let path = fields[0];
- let comment = fields[2];
- let maintainer = if fields.len() > 3 { fields[3] } else { "" };
-
- let (cat, name) = match path.split_once('/') {
- Some(p) => p,
- None => continue,
- };
-
- if count >= 200 {
- break;
- }
- html.push_str(&format!(
- "<tr><td><a href=\"/ports/{}/{}.html\">\
- {}</a></td><td>{}</td><td>{}</td></tr>\n",
- html_escape(cat),
- html_escape(name),
- html_escape(path),
- html_escape(comment),
- html_escape(maintainer),
- ));
- count += 1;
- }
-
- html.push_str("</table>\n");
- if count >= 200 {
- html.push_str("<p>Results limited to 200.</p>\n");
- }
- html.push_str(&format!(
- "<p>{} results for \"{}\"</p>\n",
- count,
- html_escape(&query)
- ));
- html.push_str("</body>\n</html>\n");
- print!("{}", html);
-}
-
-fn err(msg: &str) -> ! {
- eprintln!("{}: {}", NAME, msg);
- std::process::exit(1);
-}
-
-fn errio(path: &Path, e: &std::io::Error) -> ! {
- err(&format!(
- "{}: {}",
- path.display(),
- e.kind()
- .to_string()
- .replace("entity not found", "No such file or directory")
- .replace("permission denied", "Permission denied")
- ))
-}
-
-fn usage() -> ! {
- eprintln!("usage: {} [-d dump] [-p portsdir] [-o outdir]", NAME);
- std::process::exit(1);
-}
-
-fn main() {
- // CGI mode: QUERY_STRING is set by the HTTP server
- if std::env::var("QUERY_STRING").is_ok() {
- let index = PathBuf::from("INDEX.txt");
- #[cfg(target_os = "openbsd")]
- {
- sandbox::do_unveil(&index, "r");
- sandbox::lock_unveil();
- sandbox::do_pledge("stdio rpath");
- }
- cgi_search(&index);
- return;
- }
-
- let mut portsdir = PathBuf::from("/usr/ports");
- let mut outdir = PathBuf::from("site");
- let mut dumpfile: Option<PathBuf> = None;
-
- let args: Vec<String> = std::env::args().collect();
- let mut i = 1;
- while i < args.len() {
- match args[i].as_str() {
- "-d" => {
- i += 1;
- if i >= args.len() {
- usage();
- }
- dumpfile = Some(PathBuf::from(&args[i]));
- }
- "-p" => {
- i += 1;
- if i >= args.len() {
- usage();
- }
- portsdir = PathBuf::from(&args[i]);
- }
- "-o" => {
- i += 1;
- if i >= args.len() {
- usage();
- }
- outdir = PathBuf::from(&args[i]);
- }
- _ => usage(),
- }
- i += 1;
- }
-
- fs::create_dir_all(&outdir).unwrap_or_else(|e| {
- errio(&outdir, &e);
- });
-
- #[cfg(target_os = "openbsd")]
- {
- sandbox::do_unveil(&portsdir, "r");
- if let Some(ref d) = dumpfile {
- sandbox::do_unveil(d, "r");
- }
- sandbox::do_unveil(&outdir, "rwc");
- sandbox::lock_unveil();
- sandbox::do_pledge("stdio rpath wpath cpath");
- }
-
- let ports = if let Some(ref dump) = dumpfile {
- eprintln!("loading dump from {}", dump.display());
- let pd = if portsdir.is_dir() {
- Some(portsdir.as_path())
- } else {
- None
- };
- load_dump(dump, pd)
- } else {
- if !portsdir.is_dir() {
- err(&format!("{}: not a directory", portsdir.display()));
- }
- eprintln!("parsing ports from {}", portsdir.display());
- walk_ports(&portsdir)
- };
- eprintln!("{} ports found", ports.len());
-
- eprintln!("generating site in {}", outdir.display());
- fs::create_dir_all(outdir.join("categories")).ok();
- fs::create_dir_all(outdir.join("search")).ok();
- for p in &ports {
- fs::create_dir_all(outdir.join("ports").join(&p.category)).ok();
- }
- copy_assets(&outdir);
- let ft = footer(ports.len());
- write_index(&ports, &outdir, &ft);
- write_categories(&ports, &outdir, &ft);
- write_port_pages(&ports, &outdir, &ft);
- write_alpha(&ports, &outdir, &ft);
- write_index_txt(&ports, &outdir);
- eprintln!("done");
-}
blob - /dev/null
blob + bd0cb8133b25ae841efb9230ee35a15ecb3f66a6 (mode 644)
--- /dev/null
+++ wop.rs
+// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et :
+//
+// Copyright (c) 2024-2026 Murilo Ijanc' <murilo@ijanc.org>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+//
+use std::{
+ collections::HashMap,
+ fs,
+ io::Write,
+ path::{Path, PathBuf},
+};
+
+#[cfg(target_os = "openbsd")]
+mod sandbox {
+ use std::ffi::CString;
+ use std::os::raw::c_char;
+ use std::path::Path;
+
+ extern "C" {
+ fn pledge(promises: *const c_char, execpromises: *const c_char) -> i32;
+ fn unveil(path: *const c_char, permissions: *const c_char) -> i32;
+ }
+
+ pub fn do_unveil(path: &Path, perms: &str) {
+ let p = CString::new(path.to_str().expect("unveil: invalid path"))
+ .expect("unveil: nul in path");
+ let f = CString::new(perms).expect("unveil: nul in perms");
+ if unsafe { unveil(p.as_ptr(), f.as_ptr()) } == -1 {
+ let e = std::io::Error::last_os_error();
+ super::err(&format!("unveil {}: {}", path.display(), e));
+ }
+ }
+
+ pub fn lock_unveil() {
+ if unsafe { unveil(std::ptr::null(), std::ptr::null()) } == -1 {
+ let e = std::io::Error::last_os_error();
+ super::err(&format!("unveil lock: {}", e));
+ }
+ }
+
+ pub fn do_pledge(promises: &str) {
+ let p = CString::new(promises).expect("pledge: nul in promises");
+ if unsafe { pledge(p.as_ptr(), std::ptr::null()) } == -1 {
+ let e = std::io::Error::last_os_error();
+ super::err(&format!("pledge: {}", e));
+ }
+ }
+}
+
+const VERSION: &str = "0.1.0";
+const NAME: &str = "wop";
+const FAVICON: &[u8] = include_bytes!("assets/favicon.ico");
+const LOGO: &[u8] = include_bytes!("assets/logo.png");
+const CSS: &str = "\
+body{font-family:monospace;max-width:72em;margin:0 auto;padding:1em;\
+color:#222;background:#fff}\
+h1,h2{margin:.5em 0}\
+table{border-collapse:collapse;width:100%}\
+th,td{text-align:left;padding:.2em .5em;border-bottom:1px solid #ccc}\
+th{border-bottom:2px solid #333}\
+a{color:#00e}a:visited{color:#551a8b}\
+pre{overflow-x:auto}\
+footer{color:#666;font-size:.9em;margin-top:1em}\
+img.logo{width:4em;height:4em;vertical-align:middle;margin-right:.5em}\
+@media(prefers-color-scheme:dark){\
+body{color:#ddd;background:#1a1a1a}\
+th{border-bottom-color:#888}\
+th,td{border-bottom-color:#444}\
+a{color:#6bf}a:visited{color:#c9f}\
+footer{color:#888}\
+hr{border-color:#444}}\
+";
+
+struct Port {
+ name: String,
+ category: String,
+ comment: String,
+ descr: String,
+ homepage: String,
+ maintainer: String,
+ categories: Vec<String>,
+ distname: String,
+ pkgname: String,
+ lib_depends: Vec<String>,
+ run_depends: Vec<String>,
+ build_depends: Vec<String>,
+ multi_packages: Vec<String>,
+ sub_comments: HashMap<String, String>,
+}
+
+fn apply_modifier(val: &str, modifier: &str) -> String {
+ if modifier == "R" {
+ // :R - root (strip extension)
+ val.rsplit_once('.')
+ .map(|(r, _)| r.to_string())
+ .unwrap_or_else(|| val.to_string())
+ } else if modifier == "E" {
+ // :E - extension
+ val.rsplit_once('.')
+ .map(|(_, e)| e.to_string())
+ .unwrap_or_default()
+ } else if modifier == "L" {
+ // :L - lowercase
+ val.to_lowercase()
+ } else if modifier == "U" {
+ // :U - uppercase
+ val.to_uppercase()
+ } else if let Some(rest) = modifier.strip_prefix("S/") {
+ // :S/old/new/ - substitute first
+ if let Some((old, tail)) = rest.split_once('/') {
+ let new = tail.trim_end_matches('/');
+ val.replacen(old, new, 1)
+ } else {
+ val.to_string()
+ }
+ } else if let Some(rest) = modifier.strip_prefix("C/") {
+ // :C/pat/rep/ - substitute all (simplified, no regex)
+ if let Some((old, tail)) = rest.split_once('/') {
+ let new = tail.trim_end_matches('/');
+ val.replace(old, new)
+ } else {
+ val.to_string()
+ }
+ } else {
+ val.to_string()
+ }
+}
+
+fn expand_vars(s: &str, vars: &HashMap<String, String>) -> String {
+ let mut result = s.to_string();
+ for _ in 0..8 {
+ let prev = result.clone();
+ let mut out = String::new();
+ let mut chars = result.as_str();
+
+ while let Some(pos) = chars.find("${") {
+ out.push_str(&chars[..pos]);
+ let rest = &chars[pos + 2..];
+ let Some(end) = rest.find('}') else {
+ out.push_str(&chars[pos..]);
+ chars = "";
+ break;
+ };
+ let expr = &rest[..end];
+ let (varname, modifiers) =
+ if let Some((v, m)) = expr.split_once(':') {
+ (v, Some(m))
+ } else {
+ (expr, None)
+ };
+
+ if let Some(val) = vars.get(varname) {
+ let val = if let Some(mods) = modifiers {
+ if val.contains("${") {
+ out.push_str(&format!("${{{}}}", expr));
+ chars = &rest[end + 1..];
+ continue;
+ }
+ let mut v = val.clone();
+ for m in mods.split(':') {
+ v = apply_modifier(&v, m);
+ }
+ v
+ } else {
+ val.clone()
+ };
+ out.push_str(&val);
+ } else {
+ out.push_str(&format!("${{{}}}", expr));
+ }
+ chars = &rest[end + 1..];
+ }
+ out.push_str(chars);
+ result = out;
+
+ if result == prev {
+ break;
+ }
+ }
+ result
+}
+
+type ModCache = HashMap<String, Vec<(String, String)>>;
+
+fn load_module(
+ module: &str,
+ portsdir: &Path,
+ cache: &mut ModCache,
+) -> Option<()> {
+ if cache.contains_key(module) {
+ return Some(());
+ }
+
+ let modname = module.split('/').next_back().unwrap_or(module);
+ let mk_path = portsdir.join(module).join(format!("{}.port.mk", modname));
+ let mk_path = if mk_path.exists() {
+ mk_path
+ } else {
+ let alt = portsdir
+ .join("infrastructure/mk")
+ .join(format!("{}.port.mk", modname));
+ if alt.exists() {
+ alt
+ } else {
+ cache.insert(module.to_string(), Vec::new());
+ return None;
+ }
+ };
+
+ let text = fs::read_to_string(&mk_path).ok()?;
+ let mut modvars = Vec::new();
+
+ for line in text.lines() {
+ let line = line.trim();
+ if line.starts_with('#')
+ || line.starts_with('.')
+ || !line.contains("MOD")
+ {
+ continue;
+ }
+ if let Some(pos) = line.find("?=") {
+ let k = line[..pos].trim();
+ if k.starts_with("MOD") {
+ let v = line[pos + 2..].trim();
+ modvars.push((k.to_string(), v.to_string()));
+ }
+ } else if let Some(pos) = line.find('=') {
+ if line[..pos].ends_with('+') {
+ continue;
+ }
+ let k = line[..pos].trim();
+ if k.starts_with("MOD") {
+ let v = line[pos + 1..].trim();
+ let v = v.trim_end_matches('\\').trim();
+ modvars.push((k.to_string(), v.to_string()));
+ }
+ }
+ }
+
+ cache.insert(module.to_string(), modvars);
+ Some(())
+}
+
+fn resolve_modules(
+ vars: &mut HashMap<String, String>,
+ portsdir: &Path,
+ cache: &mut ModCache,
+) {
+ let modules = vars.get("MODULES").cloned().unwrap_or_default();
+
+ for module in modules.split_whitespace() {
+ if module.contains("..") {
+ continue;
+ }
+ load_module(module, portsdir, cache);
+ if let Some(modvars) = cache.get(module) {
+ for (k, v) in modvars {
+ vars.entry(k.clone()).or_insert_with(|| v.clone());
+ }
+ }
+ }
+}
+
+fn parse_makefile(path: &Path) -> Option<HashMap<String, String>> {
+ let text = fs::read_to_string(path).ok()?;
+ let mut vars: HashMap<String, String> = HashMap::new();
+ let mut continued = String::new();
+ let mut cont_key = String::new();
+
+ for line in text.lines() {
+ let line = line.trim_end();
+
+ if !continued.is_empty() {
+ let part = line.trim_start();
+ if part.ends_with('\\') {
+ continued.push(' ');
+ continued.push_str(part.trim_end_matches('\\').trim());
+ } else {
+ continued.push(' ');
+ continued.push_str(part);
+ vars.insert(cont_key.clone(), continued.trim().to_string());
+ continued.clear();
+ cont_key.clear();
+ }
+ continue;
+ }
+
+ if line.starts_with('#')
+ || line.starts_with('.')
+ || line.starts_with('\t')
+ {
+ continue;
+ }
+
+ let (key, val) = if let Some(pos) = line.find("+=") {
+ let k = line[..pos].trim();
+ let v = line[pos + 2..].trim();
+ let prev = vars.get(k).cloned().unwrap_or_default();
+ (
+ k.to_string(),
+ format!("{} {}", prev, v.trim_end_matches('\\').trim()),
+ )
+ } else if let Some(pos) = line.find("?=") {
+ let k = line[..pos].trim();
+ if vars.contains_key(k) {
+ continue;
+ }
+ let v = line[pos + 2..].trim();
+ (k.to_string(), v.trim_end_matches('\\').trim().to_string())
+ } else if let Some(pos) = line.find('=') {
+ let k = line[..pos].trim();
+ let v = line[pos + 1..].trim();
+ (k.to_string(), v.trim_end_matches('\\').trim().to_string())
+ } else {
+ continue;
+ };
+
+ if line.ends_with('\\') {
+ continued = val;
+ cont_key = key;
+ } else {
+ vars.insert(key, val);
+ }
+ }
+
+ if !cont_key.is_empty() {
+ vars.insert(cont_key, continued.trim().to_string());
+ }
+
+ Some(vars)
+}
+
+fn safe_name(s: &str) -> bool {
+ !s.is_empty()
+ && !s.contains('/')
+ && !s.contains('\0')
+ && s != "."
+ && s != ".."
+}
+
+fn port_from_makefile(
+ category: &str,
+ portname: &str,
+ portdir: &Path,
+ portsdir: &Path,
+ modcache: &mut ModCache,
+) -> Option<Port> {
+ if !safe_name(category) || !safe_name(portname) {
+ return None;
+ }
+ let mf = portdir.join("Makefile");
+ let mut vars = parse_makefile(&mf)?;
+
+ resolve_modules(&mut vars, portsdir, modcache);
+
+ let get = |k: &str| -> String {
+ let raw = vars.get(k).cloned().unwrap_or_default();
+ expand_vars(&raw, &vars)
+ };
+
+ let mut comment = get("COMMENT");
+ if comment.is_empty() {
+ comment = get("COMMENT-main");
+ }
+ if comment.is_empty() {
+ return None;
+ }
+
+ let descr_path = portdir.join("pkg").join("DESCR");
+ let descr = fs::read_to_string(&descr_path).unwrap_or_default();
+
+ let categories: Vec<String> = get("CATEGORIES")
+ .split_whitespace()
+ .map(String::from)
+ .collect();
+
+ let split_deps = |val: &str| -> Vec<String> {
+ val.split_whitespace()
+ .map(|s| expand_vars(s, &vars))
+ .filter(|s| !s.is_empty() && !s.contains("${"))
+ .collect()
+ };
+
+ let multi_packages: Vec<String> = get("MULTI_PACKAGES")
+ .split_whitespace()
+ .map(String::from)
+ .collect();
+
+ let mut sub_comments = HashMap::new();
+ for sub in &multi_packages {
+ let key = format!("COMMENT{}", sub);
+ if let Some(val) = vars.get(&key) {
+ sub_comments.insert(sub.clone(), expand_vars(val, &vars));
+ }
+ }
+
+ Some(Port {
+ name: portname.to_string(),
+ category: category.to_string(),
+ comment,
+ descr,
+ homepage: get("HOMEPAGE"),
+ maintainer: get("MAINTAINER"),
+ categories,
+ distname: get("DISTNAME"),
+ pkgname: get("PKGNAME"),
+ lib_depends: split_deps(&get("LIB_DEPENDS")),
+ run_depends: split_deps(&get("RUN_DEPENDS")),
+ build_depends: split_deps(&get("BUILD_DEPENDS")),
+ multi_packages,
+ sub_comments,
+ })
+}
+
+fn port_from_vars(
+ path: &str,
+ vars: &HashMap<String, String>,
+ portsdir: Option<&Path>,
+) -> Option<Port> {
+ let (category, name) = path.split_once('/')?;
+ if !safe_name(category) || !safe_name(name) {
+ return None;
+ }
+ let comment = vars.get("COMMENT").filter(|s| !s.is_empty())?;
+
+ let descr = if let Some(pd) = portsdir {
+ let descr_path = pd.join(path).join("pkg").join("DESCR");
+ fs::read_to_string(&descr_path).unwrap_or_default()
+ } else {
+ String::new()
+ };
+
+ let split = |k: &str| -> Vec<String> {
+ vars.get(k)
+ .map(|v| v.split_whitespace().map(String::from).collect())
+ .unwrap_or_default()
+ };
+
+ Some(Port {
+ name: name.to_string(),
+ category: category.to_string(),
+ comment: comment.clone(),
+ descr,
+ homepage: vars.get("HOMEPAGE").cloned().unwrap_or_default(),
+ maintainer: vars.get("MAINTAINER").cloned().unwrap_or_default(),
+ categories: split("CATEGORIES"),
+ distname: String::new(),
+ pkgname: vars.get("FULLPKGNAME").cloned().unwrap_or_default(),
+ lib_depends: split("LIB_DEPENDS"),
+ run_depends: split("RUN_DEPENDS"),
+ build_depends: split("BUILD_DEPENDS"),
+ multi_packages: split("MULTI_PACKAGES"),
+ sub_comments: HashMap::new(),
+ })
+}
+
+fn load_dump(path: &Path, portsdir: Option<&Path>) -> Vec<Port> {
+ let text = fs::read_to_string(path).unwrap_or_else(|e| errio(path, &e));
+
+ let mut by_port: HashMap<String, HashMap<String, String>> = HashMap::new();
+
+ for line in text.lines() {
+ // format: category/port.VARIABLE=value
+ // find the first '=' then look backwards for the '.'
+ let Some(eq_pos) = line.find('=') else {
+ continue;
+ };
+ let prefix = &line[..eq_pos];
+ let Some(dot) = prefix.rfind('.') else {
+ continue;
+ };
+ let portpath = &line[..dot];
+ let var = &line[dot + 1..eq_pos];
+ let val = &line[eq_pos + 1..];
+ by_port
+ .entry(portpath.to_string())
+ .or_default()
+ .insert(var.to_string(), val.to_string());
+ }
+
+ let mut ports = Vec::new();
+ let mut paths: Vec<_> = by_port.keys().cloned().collect();
+ paths.sort();
+
+ for path in paths {
+ let vars = &by_port[&path];
+ if let Some(port) = port_from_vars(&path, vars, portsdir) {
+ ports.push(port);
+ }
+ }
+
+ ports
+}
+
+fn walk_ports(portsdir: &Path) -> Vec<Port> {
+ let mut ports = Vec::new();
+ let mut modcache = ModCache::new();
+ let skip = [
+ "infrastructure",
+ "tests",
+ ".git",
+ ".gitignore",
+ ".cvsignore",
+ ];
+
+ let mut cats: Vec<_> = fs::read_dir(portsdir)
+ .unwrap_or_else(|e| errio(portsdir, &e))
+ .filter_map(|e| e.ok())
+ .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
+ .filter(|e| {
+ let n = e.file_name();
+ !skip.contains(&n.to_str().unwrap_or(""))
+ })
+ .collect();
+ cats.sort_by_key(|e| e.file_name());
+
+ for cat in cats {
+ let catname = cat.file_name().to_string_lossy().to_string();
+ let mut entries: Vec<_> = match fs::read_dir(cat.path()) {
+ Ok(rd) => rd
+ .filter_map(|e| e.ok())
+ .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
+ .collect(),
+ Err(_) => continue,
+ };
+ entries.sort_by_key(|e| e.file_name());
+
+ for entry in entries {
+ let portname = entry.file_name().to_string_lossy().to_string();
+ if let Some(port) = port_from_makefile(
+ &catname,
+ &portname,
+ &entry.path(),
+ portsdir,
+ &mut modcache,
+ ) {
+ ports.push(port);
+ }
+ }
+ }
+
+ ports
+}
+
+fn head(title: &str, depth: usize, extra: &str) -> String {
+ let rel = "../".repeat(depth);
+ format!(
+ "<!DOCTYPE html>\n<html>\n<head>\
+ <meta charset=\"utf-8\">\
+ <meta name=\"viewport\" \
+ content=\"width=device-width,initial-scale=1\">\
+ <title>{}</title>\
+ <link rel=\"icon\" href=\"{}favicon.ico\">\
+ {}\
+ <style>{}</style></head>\n<body>\n",
+ html_escape(title),
+ rel,
+ extra,
+ CSS
+ )
+}
+
+fn port_meta(p: &Port, depth: usize) -> String {
+ let rel = "../".repeat(depth);
+ let keywords: Vec<&str> = p.categories.iter().map(|s| s.as_str()).collect();
+ format!(
+ "<meta name=\"description\" content=\"{}\">\
+ <meta name=\"author\" content=\"{}\">\
+ <meta name=\"keywords\" content=\"{}\">\
+ <meta property=\"og:title\" content=\"{}\">\
+ <meta property=\"og:description\" content=\"{}\">\
+ <meta property=\"og:type\" content=\"website\">\
+ <meta property=\"og:image\" content=\"{}logo.png\">",
+ html_escape(&p.comment),
+ html_escape(&p.maintainer),
+ html_escape(&keywords.join(", ")),
+ html_escape(&format!("{}/{}", p.category, p.name)),
+ html_escape(&p.comment),
+ rel,
+ )
+}
+
+fn html_escape(s: &str) -> String {
+ if !s.contains(&['&', '<', '>', '"', '\''][..]) {
+ return s.to_string();
+ }
+ let mut out = String::with_capacity(s.len() + 8);
+ for c in s.chars() {
+ match c {
+ '&' => out.push_str("&"),
+ '<' => out.push_str("<"),
+ '>' => out.push_str(">"),
+ '"' => out.push_str("""),
+ '\'' => out.push_str("'"),
+ _ => out.push(c),
+ }
+ }
+ out
+}
+
+fn local_date() -> String {
+ use std::os::raw::{c_int, c_long};
+
+ // struct tm layout per POSIX, with BSD extensions.
+ // over-sized _pad absorbs any platform-specific trailing
+ // fields so localtime_r never writes out of bounds.
+ #[repr(C)]
+ struct Tm {
+ tm_sec: c_int,
+ tm_min: c_int,
+ tm_hour: c_int,
+ tm_mday: c_int,
+ tm_mon: c_int,
+ tm_year: c_int,
+ tm_wday: c_int,
+ tm_yday: c_int,
+ tm_isdst: c_int,
+ _pad: [u8; 64],
+ }
+
+ extern "C" {
+ fn time(t: *mut c_long) -> c_long;
+ fn localtime_r(t: *const c_long, result: *mut Tm) -> *mut Tm;
+ }
+
+ unsafe {
+ let mut t: c_long = 0;
+ time(&mut t);
+ let mut tm = std::mem::zeroed::<Tm>();
+ let ret = localtime_r(&t, &mut tm);
+ if ret.is_null() {
+ return String::from("unknown");
+ }
+ format!(
+ "{:04}-{:02}-{:02}",
+ tm.tm_year + 1900,
+ tm.tm_mon + 1,
+ tm.tm_mday
+ )
+ }
+}
+
+fn footer(total: usize) -> String {
+ format!(
+ "<hr>\n<footer>{} {} | {} ports | {}</footer>\n",
+ NAME,
+ VERSION,
+ total,
+ local_date()
+ )
+}
+
+fn dep_link(dep: &str) -> String {
+ // dependency format: "category/port" or "pkg:category/port"
+ // or "category/port,subpkg" or "category/port>=version"
+ let dep = dep.split(':').next_back().unwrap_or(dep);
+ let path = dep.split(['>', '<', '=']).next().unwrap_or(dep);
+ let path = path.trim_end_matches(',');
+
+ if let Some((cat, port)) = path.split_once('/') {
+ let port = port.split(',').next().unwrap_or(port);
+ format!(
+ "<a href=\"../{}/{}.html\">{}</a>",
+ html_escape(cat),
+ html_escape(port),
+ html_escape(dep)
+ )
+ } else {
+ html_escape(dep)
+ }
+}
+
+fn write_file(path: &Path, content: &str) {
+ let mut f = fs::File::create(path).unwrap_or_else(|e| errio(path, &e));
+ if let Err(e) = f.write_all(content.as_bytes()) {
+ errio(path, &e);
+ }
+}
+
+fn copy_assets(outdir: &Path) {
+ let fav = outdir.join("favicon.ico");
+ if let Err(e) = fs::write(&fav, FAVICON) {
+ errio(&fav, &e);
+ }
+ let logo = outdir.join("logo.png");
+ if let Err(e) = fs::write(&logo, LOGO) {
+ errio(&logo, &e);
+ }
+}
+
+fn write_index(ports: &[Port], outdir: &Path, ft: &str) {
+ let mut cats: HashMap<&str, usize> = HashMap::new();
+ for p in ports {
+ *cats.entry(&p.category).or_default() += 1;
+ }
+ let mut sorted: Vec<_> = cats.into_iter().collect();
+ sorted.sort_by_key(|&(k, _)| k);
+
+ let mut html = head("OpenBSD Ports", 0, "");
+ html.push_str(
+ "<h1><img class=\"logo\" src=\"logo.png\" \
+ alt=\"\">OpenBSD Ports</h1>\n",
+ );
+
+ html.push_str("<!-- SEARCH -->\n");
+
+ html.push_str("<h2>Alphabetical Index</h2>\n<p>\n");
+ for c in b'a'..=b'z' {
+ let c = c as char;
+ html.push_str(&format!(
+ "<a href=\"search/{}.html\">{}</a> \n",
+ c,
+ c.to_ascii_uppercase()
+ ));
+ }
+ html.push_str("</p>\n");
+
+ html.push_str("<h2>Categories</h2>\n<ul>\n");
+ for (cat, count) in &sorted {
+ html.push_str(&format!(
+ "<li><a href=\"categories/{}.html\">{}</a> \
+ ({})</li>\n",
+ cat, cat, count
+ ));
+ }
+ html.push_str("</ul>\n");
+
+ html.push_str(ft);
+ html.push_str("</body>\n</html>\n");
+
+ write_file(&outdir.join("index.html"), &html);
+}
+
+fn write_categories(ports: &[Port], outdir: &Path, ft: &str) {
+ let mut by_cat: HashMap<&str, Vec<&Port>> = HashMap::new();
+ for p in ports {
+ by_cat.entry(&p.category).or_default().push(p);
+ }
+
+ let catdir = outdir.join("categories");
+
+ for (cat, mut cat_ports) in by_cat {
+ cat_ports.sort_by(|a, b| a.name.cmp(&b.name));
+
+ let mut html = head(&format!("{} - OpenBSD Ports", cat), 1, "");
+ html.push_str(&format!(
+ "<h1>{}</h1>\n\
+ <p><a href=\"../index.html\">Index</a></p>\n\
+ <table>\n<tr><th>Port</th><th>Description</th>\
+ </tr>\n",
+ cat
+ ));
+
+ for p in &cat_ports {
+ html.push_str(&format!(
+ "<tr><td><a href=\"../ports/{}/{}.html\">\
+ {}</a></td><td>{}</td></tr>\n",
+ html_escape(&p.category),
+ html_escape(&p.name),
+ html_escape(&p.name),
+ html_escape(&p.comment)
+ ));
+ }
+
+ html.push_str("</table>\n");
+ html.push_str(ft);
+ html.push_str("</body>\n</html>\n");
+
+ write_file(&catdir.join(format!("{}.html", cat)), &html);
+ }
+}
+
+fn write_port_pages(ports: &[Port], outdir: &Path, ft: &str) {
+ for p in ports {
+ let meta = port_meta(p, 2);
+ let mut html = head(
+ &format!("{}/{} - OpenBSD Ports", &p.category, &p.name),
+ 2,
+ &meta,
+ );
+
+ let display_name = if !p.pkgname.is_empty() && !p.pkgname.contains("${")
+ {
+ &p.pkgname
+ } else if !p.distname.is_empty() && !p.distname.contains("${") {
+ &p.distname
+ } else {
+ &p.name
+ };
+ html.push_str(&format!("<h1>{}</h1>\n", html_escape(display_name)));
+
+ html.push_str(&format!("<p>{}</p>\n", html_escape(&p.comment)));
+
+ html.push_str(&format!(
+ "<p><a href=\"../../index.html\">Index</a> | \
+ <a href=\"../../categories/{}.html\">{}</a></p>\n",
+ html_escape(&p.category),
+ html_escape(&p.category)
+ ));
+
+ if !p.descr.is_empty() {
+ html.push_str(&format!(
+ "<h2>Description</h2>\n<pre>{}</pre>\n",
+ html_escape(&p.descr)
+ ));
+ }
+
+ if !p.homepage.is_empty()
+ && (p.homepage.starts_with("https://")
+ || p.homepage.starts_with("http://"))
+ {
+ html.push_str(&format!(
+ "<p>Homepage: <a href=\"{}\">{}</a></p>\n",
+ html_escape(&p.homepage),
+ html_escape(&p.homepage)
+ ));
+ }
+
+ if !p.maintainer.is_empty() {
+ html.push_str(&format!(
+ "<p>Maintainer: {}</p>\n",
+ html_escape(&p.maintainer)
+ ));
+ }
+
+ if p.categories.len() > 1 {
+ html.push_str("<p>Categories: ");
+ for (i, cat) in p.categories.iter().enumerate() {
+ if i > 0 {
+ html.push_str(", ");
+ }
+ html.push_str(&format!(
+ "<a href=\"../../categories/{}.html\">\
+ {}</a>",
+ html_escape(cat),
+ html_escape(cat)
+ ));
+ }
+ html.push_str("</p>\n");
+ }
+
+ if !p.multi_packages.is_empty() {
+ html.push_str("<h2>Sub-packages</h2>\n<ul>\n");
+ for sub in &p.multi_packages {
+ let comment =
+ p.sub_comments.get(sub).map(|s| s.as_str()).unwrap_or("");
+ html.push_str(&format!(
+ "<li>{} - {}</li>\n",
+ html_escape(sub),
+ html_escape(comment)
+ ));
+ }
+ html.push_str("</ul>\n");
+ }
+
+ let dep_section = |title: &str, deps: &[String]| -> String {
+ if deps.is_empty() {
+ return String::new();
+ }
+ let mut s = format!("<h2>{}</h2>\n<ul>\n", title);
+ for d in deps {
+ s.push_str(&format!("<li>{}</li>\n", dep_link(d)));
+ }
+ s.push_str("</ul>\n");
+ s
+ };
+
+ html.push_str(&dep_section("Library Dependencies", &p.lib_depends));
+ html.push_str(&dep_section("Run Dependencies", &p.run_depends));
+ html.push_str(&dep_section("Build Dependencies", &p.build_depends));
+
+ html.push_str(ft);
+ html.push_str("</body>\n</html>\n");
+
+ write_file(
+ &outdir
+ .join("ports")
+ .join(&p.category)
+ .join(format!("{}.html", p.name)),
+ &html,
+ );
+ }
+}
+
+fn write_alpha(ports: &[Port], outdir: &Path, ft: &str) {
+ let searchdir = outdir.join("search");
+
+ for c in b'a'..=b'z' {
+ let c = c as char;
+ let mut matching: Vec<&Port> = ports
+ .iter()
+ .filter(|p| {
+ p.name
+ .chars()
+ .next()
+ .map(|ch| ch.to_ascii_lowercase() == c)
+ .unwrap_or(false)
+ })
+ .collect();
+ matching.sort_by(|a, b| a.name.cmp(&b.name));
+
+ let mut html = head(
+ &format!("{} - OpenBSD Ports", c.to_ascii_uppercase()),
+ 1,
+ "",
+ );
+ html.push_str(&format!(
+ "<h1>Ports: {}</h1>\n\
+ <p><a href=\"../index.html\">Index</a></p>\n\
+ <p>\n",
+ c.to_ascii_uppercase()
+ ));
+
+ for lc in b'a'..=b'z' {
+ let lc = lc as char;
+ if lc == c {
+ html.push_str(&format!(
+ "<b>{}</b> \n",
+ lc.to_ascii_uppercase()
+ ));
+ } else {
+ html.push_str(&format!(
+ "<a href=\"{}.html\">{}</a> \n",
+ lc,
+ lc.to_ascii_uppercase()
+ ));
+ }
+ }
+ html.push_str("</p>\n");
+
+ html.push_str(
+ "<table>\n<tr><th>Port</th><th>Category</th>\
+ <th>Description</th></tr>\n",
+ );
+ for p in &matching {
+ html.push_str(&format!(
+ "<tr><td><a href=\"../ports/{}/{}.html\">\
+ {}</a></td><td><a href=\"../categories/\
+ {}.html\">{}</a></td><td>{}</td></tr>\n",
+ html_escape(&p.category),
+ html_escape(&p.name),
+ html_escape(&p.name),
+ html_escape(&p.category),
+ html_escape(&p.category),
+ html_escape(&p.comment)
+ ));
+ }
+
+ html.push_str("</table>\n");
+ html.push_str(ft);
+ html.push_str("</body>\n</html>\n");
+
+ write_file(&searchdir.join(format!("{}.html", c)), &html);
+ }
+}
+
+fn sanitize_field(s: &str) -> String {
+ s.replace(['\t', '\n'], " ").replace('\r', "")
+}
+
+fn write_index_txt(ports: &[Port], outdir: &Path) {
+ let mut out = String::new();
+ for p in ports {
+ // format: category/name\tpkgname\tcomment\tmaintainer
+ out.push_str(&p.category);
+ out.push('/');
+ out.push_str(&p.name);
+ out.push('\t');
+ out.push_str(&sanitize_field(&p.pkgname));
+ out.push('\t');
+ out.push_str(&sanitize_field(&p.comment));
+ out.push('\t');
+ out.push_str(&sanitize_field(&p.maintainer));
+ out.push('\n');
+ }
+ write_file(&outdir.join("INDEX.txt"), &out);
+}
+
+fn url_decode(s: &str) -> String {
+ let mut out = Vec::with_capacity(s.len());
+ let mut bytes = s.as_bytes().iter();
+ while let Some(&b) = bytes.next() {
+ if b == b'%' {
+ let hi = bytes.next().copied().unwrap_or(0);
+ let lo = bytes.next().copied().unwrap_or(0);
+ let hex = [hi, lo];
+ if let Ok(s) = std::str::from_utf8(&hex) {
+ if let Ok(n) = u8::from_str_radix(s, 16) {
+ if n != 0 {
+ out.push(n);
+ }
+ continue;
+ }
+ }
+ out.push(b);
+ } else if b == b'+' {
+ out.push(b' ');
+ } else {
+ out.push(b);
+ }
+ }
+ String::from_utf8_lossy(&out).into_owned()
+}
+
+fn cgi_search(index_path: &Path) {
+ let mut qs = std::env::var("QUERY_STRING").unwrap_or_default();
+ qs.truncate(1024);
+ let mut query = qs
+ .split('&')
+ .find_map(|p| p.strip_prefix("q=").map(url_decode))
+ .unwrap_or_default()
+ .to_lowercase();
+ query.truncate(128);
+
+ print!("Content-Type: text/html\r\n\r\n");
+
+ let mut html = head("Search - OpenBSD Ports", 0, "");
+ html.push_str("<h1>Search Results</h1>\n");
+ html.push_str(
+ "<form action=\"search.cgi\" method=\"get\">\n\
+ <input type=\"text\" name=\"q\" size=\"40\"",
+ );
+ if !query.is_empty() {
+ html.push_str(&format!(" value=\"{}\"", html_escape(&query)));
+ }
+ html.push_str(
+ ">\n<input type=\"submit\" value=\"Search\">\n\
+ </form>\n",
+ );
+ html.push_str("<p><a href=\"/index.html\">Index</a></p>\n");
+
+ if query.is_empty() {
+ html.push_str("<p>Enter a search term.</p>\n");
+ html.push_str("</body>\n</html>\n");
+ print!("{}", html);
+ return;
+ }
+
+ let text = fs::read_to_string(index_path).unwrap_or_default();
+
+ html.push_str(
+ "<table>\n<tr><th>Port</th><th>Description</th>\
+ <th>Maintainer</th></tr>\n",
+ );
+
+ let mut count = 0;
+ for line in text.lines() {
+ let lower = line.to_lowercase();
+ if !lower.contains(&query) {
+ continue;
+ }
+ let fields: Vec<&str> = line.splitn(4, '\t').collect();
+ if fields.len() < 3 {
+ continue;
+ }
+ let path = fields[0];
+ let comment = fields[2];
+ let maintainer = if fields.len() > 3 { fields[3] } else { "" };
+
+ let (cat, name) = match path.split_once('/') {
+ Some(p) => p,
+ None => continue,
+ };
+
+ if count >= 200 {
+ break;
+ }
+ html.push_str(&format!(
+ "<tr><td><a href=\"/ports/{}/{}.html\">\
+ {}</a></td><td>{}</td><td>{}</td></tr>\n",
+ html_escape(cat),
+ html_escape(name),
+ html_escape(path),
+ html_escape(comment),
+ html_escape(maintainer),
+ ));
+ count += 1;
+ }
+
+ html.push_str("</table>\n");
+ if count >= 200 {
+ html.push_str("<p>Results limited to 200.</p>\n");
+ }
+ html.push_str(&format!(
+ "<p>{} results for \"{}\"</p>\n",
+ count,
+ html_escape(&query)
+ ));
+ html.push_str("</body>\n</html>\n");
+ print!("{}", html);
+}
+
+fn err(msg: &str) -> ! {
+ eprintln!("{}: {}", NAME, msg);
+ std::process::exit(1);
+}
+
+fn errio(path: &Path, e: &std::io::Error) -> ! {
+ err(&format!(
+ "{}: {}",
+ path.display(),
+ e.kind()
+ .to_string()
+ .replace("entity not found", "No such file or directory")
+ .replace("permission denied", "Permission denied")
+ ))
+}
+
+fn usage() -> ! {
+ eprintln!("usage: {} [-d dump] [-p portsdir] [-o outdir]", NAME);
+ std::process::exit(1);
+}
+
+fn main() {
+ // CGI mode: QUERY_STRING is set by the HTTP server
+ if std::env::var("QUERY_STRING").is_ok() {
+ let index = PathBuf::from("INDEX.txt");
+ #[cfg(target_os = "openbsd")]
+ {
+ sandbox::do_unveil(&index, "r");
+ sandbox::lock_unveil();
+ sandbox::do_pledge("stdio rpath");
+ }
+ cgi_search(&index);
+ return;
+ }
+
+ let mut portsdir = PathBuf::from("/usr/ports");
+ let mut outdir = PathBuf::from("site");
+ let mut dumpfile: Option<PathBuf> = None;
+
+ let args: Vec<String> = std::env::args().collect();
+ let mut i = 1;
+ while i < args.len() {
+ match args[i].as_str() {
+ "-d" => {
+ i += 1;
+ if i >= args.len() {
+ usage();
+ }
+ dumpfile = Some(PathBuf::from(&args[i]));
+ }
+ "-p" => {
+ i += 1;
+ if i >= args.len() {
+ usage();
+ }
+ portsdir = PathBuf::from(&args[i]);
+ }
+ "-o" => {
+ i += 1;
+ if i >= args.len() {
+ usage();
+ }
+ outdir = PathBuf::from(&args[i]);
+ }
+ _ => usage(),
+ }
+ i += 1;
+ }
+
+ fs::create_dir_all(&outdir).unwrap_or_else(|e| {
+ errio(&outdir, &e);
+ });
+
+ #[cfg(target_os = "openbsd")]
+ {
+ sandbox::do_unveil(&portsdir, "r");
+ if let Some(ref d) = dumpfile {
+ sandbox::do_unveil(d, "r");
+ }
+ sandbox::do_unveil(&outdir, "rwc");
+ sandbox::lock_unveil();
+ sandbox::do_pledge("stdio rpath wpath cpath");
+ }
+
+ let ports = if let Some(ref dump) = dumpfile {
+ eprintln!("loading dump from {}", dump.display());
+ let pd = if portsdir.is_dir() {
+ Some(portsdir.as_path())
+ } else {
+ None
+ };
+ load_dump(dump, pd)
+ } else {
+ if !portsdir.is_dir() {
+ err(&format!("{}: not a directory", portsdir.display()));
+ }
+ eprintln!("parsing ports from {}", portsdir.display());
+ walk_ports(&portsdir)
+ };
+ eprintln!("{} ports found", ports.len());
+
+ eprintln!("generating site in {}", outdir.display());
+ fs::create_dir_all(outdir.join("categories")).ok();
+ fs::create_dir_all(outdir.join("search")).ok();
+ for p in &ports {
+ fs::create_dir_all(outdir.join("ports").join(&p.category)).ok();
+ }
+ copy_assets(&outdir);
+ let ft = footer(ports.len());
+ write_index(&ports, &outdir, &ft);
+ write_categories(&ports, &outdir, &ft);
+ write_port_pages(&ports, &outdir, &ft);
+ write_alpha(&ports, &outdir, &ft);
+ write_index_txt(&ports, &outdir);
+ eprintln!("done");
+}