Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -232,4 +232,15 @@ Direct PanLL panel scripts (schemata generation, workspace layout validation) ar

This project is licensed under the Mozilla Public License, v. 2.0. See the `LICENSE` file for details.

SPDX-License-Identifier: CC-BY-SA-4.0
SPDX-License-Identifier: CC-BY-SA-4.0
== Language Compliance Reports

`check-language-compliance.sh` requires Bash and Julia (using only Julia's
standard library). It writes a dated report under
`${XDG_STATE_HOME}/scripts/language-compliance` when `XDG_STATE_HOME` is absolute,
or `$HOME/.local/state/scripts/language-compliance` otherwise.
The logger rejects symlinks in the state path and requires the state base,
`scripts`, and `language-compliance` directories to belong to the invoking user.
Reports are private (directory mode `0700`, log mode `0600`).

Run the logging regression checks with `bash tests/check-language-compliance.sh`.
98 changes: 98 additions & 0 deletions check-language-compliance-log.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/usr/bin/env julia
# SPDX-License-Identifier: MPL-2.0
# POSIX descriptor-based logger for check-language-compliance.sh.

using Base.Filesystem: JL_O_RDONLY, JL_O_WRONLY, JL_O_CREAT, JL_O_DIRECTORY,
JL_O_NOFOLLOW, JL_O_NONBLOCK, JL_O_CLOEXEC

"""
Open `name` relative to directory descriptor `dirfd` using POSIX `flags` and
return a file descriptor that the caller must close. Absolute names ignore
`dirfd`. When creating a file, `mode` specifies permissions subject to the umask.
Throw a `SystemError` if the operating system rejects the open.
"""
function openat(dirfd, name, flags, mode=0o600)
result = ccall(:openat, Cint, (Cint, Cstring, Cint, Cuint), dirfd, name, flags, mode)
Base.systemerror("openat $name", result == -1)
return result
end

"""
Create or truncate a private report and return a writable stream for the caller
to close. `state_dir` is the full, absolute report directory, normally ending in
`scripts/language-compliance`; `log_name` uses eight digits followed by `.log`
(the calendar date is not validated).

Create missing directories with mode `0700`, subject to the umask, and set the
final directory to `0700`. Reject `.` and `..` components and directory symlinks;
the final three directory components must belong to the invoking user. The log
must be a regular file owned by that user with one hard link, and must not be a
symlink. Set its mode to `0600` and truncate it only after validation.

Throw on invalid arguments or unsafe paths/files, and propagate errors from
opening, inspecting, changing permissions or truncating. Directory creation and
permission changes may remain after failure; no fallback log is returned.
"""
function private_log(state_dir, log_name)
isabspath(state_dir) || error("state directory must be absolute")
occursin(r"^[0-9]{8}\.log$", log_name) || error("invalid log name")
uid = ccall(:getuid, Cuint, ())
dirflags = JL_O_RDONLY | JL_O_DIRECTORY | JL_O_NOFOLLOW | JL_O_CLOEXEC
# Start at / and walk every component through its open parent. No directory
# symlinks are accepted, including in XDG_STATE_HOME and HOME.
dirfd = ccall(:open, Cint, (Cstring, Cint), "/", dirflags)
Base.systemerror("open /", dirfd == -1)
try
parts = split(state_dir, '/'; keepempty=false)
for (index, part) in enumerate(parts)
part in (".", "..") && error("state directory must not contain . or ..")
# mkdirat applies 0700 at creation, without a permissive interval.
# If it already exists, openat and fstat below validate the object.
ccall(:mkdirat, Cint, (Cint, Cstring, Cuint), dirfd, part, 0o700)
nextfd = openat(dirfd, part, dirflags)
ccall(:close, Cint, (Cint,), dirfd)
dirfd = nextfd
# The state base, scripts and language-compliance must be ours;
# system ancestors such as /home may belong to root.
if index >= length(parts) - 2
stat(RawFD(dirfd)).uid == uid || error("state directory is not owned by invoking user")
end
end
Base.systemerror("fchmod state directory", ccall(:fchmod, Cint, (Cint, Cuint), dirfd, 0o700) == -1)
# Do not truncate until the opened inode has passed validation. NONBLOCK
# prevents a planted FIFO from hanging the process before fstat.
logfd = openat(dirfd, log_name, JL_O_WRONLY | JL_O_CREAT | JL_O_NOFOLLOW | JL_O_NONBLOCK | JL_O_CLOEXEC)
log = Base.fdio(logfd, true)
try
info = stat(log)
isfile(info) && info.uid == uid && info.nlink == 1 || error("unsafe log file")
Base.systemerror("fchmod log", ccall(:fchmod, Cint, (Cint, Cuint), logfd, 0o600) == -1)
truncate(log, 0)
return log
catch
close(log)
rethrow()
end
finally
ccall(:close, Cint, (Cint,), dirfd)
end
end

try
length(ARGS) == 2 || error("expected state directory and dated log name")
log = private_log(ARGS...)
try
while !eof(stdin)
data = readavailable(stdin)
write(log, data) == length(data) || error("incomplete log write")
flush(log)
write(stdout, data)
flush(stdout)
end
finally
close(log)
end
catch err
println(stderr, "Language compliance logging failed: ", sprint(showerror, err))
exit(1)
end
84 changes: 52 additions & 32 deletions check-language-compliance.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,23 @@
set -euo pipefail

REPOS_BASE="${REPOS_BASE:-/var$REPOS_DIR}"
LOG_FILE="/tmp/language-compliance-$(date +%Y%m%d).log"
# Per-user XDG state, not /tmp: this log is a kept, dated compliance report
# (copied to stdout for the whole run), and a world-writable /tmp path
# with a predictable name lets another local user pre-create or tamper with
# it (CWE-377). Not a launcher, so no PID file and no launch-scaffolder/
# segment here.
case "${XDG_STATE_HOME:-}" in
/*) STATE_DIR="$XDG_STATE_HOME/scripts/language-compliance" ;;
*) STATE_DIR="$HOME/.local/state/scripts/language-compliance" ;;
esac
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"

echo "=== Language Compliance Check ===" | tee "$LOG_FILE"
echo "Started: $(date)" | tee -a "$LOG_FILE"
echo "" | tee -a "$LOG_FILE"
# The logger opens the report once using a verified directory descriptor.
{

echo "=== Language Compliance Check ==="
echo "Started: $(date)"
echo ""

# Initialize counters
API_VIOLATIONS=0
Expand All @@ -18,70 +30,75 @@ FFI_VIOLATIONS=0
COMPLIANT_REPOS=0
TOTAL_REPOS=0

# Check a single repository
# Scan repository directory $1 recursively for potential API/ABI/FFI language
# violations and print the findings to stdout. Update API_VIOLATIONS,
# ABI_VIOLATIONS, FFI_VIOLATIONS, COMPLIANT_REPOS and TOTAL_REPOS in the current
# shell; initialise these counters before calling. A completed call returns the
# final echo's status, not a compliance result. With set -e active, incrementing
# a zero counter returns status 1 and can terminate the scan early.
check_repo() {
local repo_path="$1"
local repo_name="$(basename "$repo_path")"
local violations=0

echo "Checking: $repo_name" | tee -a "$LOG_FILE"
echo "Checking: $repo_name"

# Check for non-V APIs (exclude internal scripts)
if find "$repo_path" -name "*.ex" -o -name "*.exs" | grep -q .; then
echo " ✓ Elixir found - checking API compliance" | tee -a "$LOG_FILE"
echo " ✓ Elixir found - checking API compliance"
# Elixir repos should use V for external APIs
if [ ! -f "$repo_path/api/vlang" ] && find "$repo_path" -name "*.ex" | head -1 | grep -q .; then
echo " ⚠️ Potential API violation: Elixir repo without V API layer" | tee -a "$LOG_FILE"
echo " ⚠️ Potential API violation: Elixir repo without V API layer"
((API_VIOLATIONS++))
((violations++))
fi
fi

# Check for non-Idris2 ABIs
if find "$repo_path" -name "*.zig" | grep -q .; then
echo " ✓ Zig found - checking ABI compliance" | tee -a "$LOG_FILE"
echo " ✓ Zig found - checking ABI compliance"
# Zig repos should use Idris2 for ABIs
if [ ! -f "$repo_path/abi/idris2" ] && find "$repo_path" -name "*.zig" | head -1 | grep -q .; then
echo " ⚠️ Potential ABI violation: Zig repo without Idris2 ABI layer" | tee -a "$LOG_FILE"
echo " ⚠️ Potential ABI violation: Zig repo without Idris2 ABI layer"
((ABI_VIOLATIONS++))
((violations++))
fi
fi

# Check for non-Zig FFIs
if find "$repo_path" -name "*.c" -o -name "*.h" | grep -q .; then
echo " ⚠️ Potential FFI violation: C headers found" | tee -a "$LOG_FILE"
echo " Should use Zig with C compatibility layer only" | tee -a "$LOG_FILE"
echo " ⚠️ Potential FFI violation: C headers found"
echo " Should use Zig with C compatibility layer only"
((FFI_VIOLATIONS++))
((violations++))
fi

# Check for proper C compatibility warnings
if grep -r "c_compat" "$repo_path" 2>/dev/null | grep -q .; then
if ! grep -r "compileError.*C.*compatibility" "$repo_path" 2>/dev/null | grep -q .; then
echo " ⚠️ C compatibility without proper warnings" | tee -a "$LOG_FILE"
echo " ⚠️ C compatibility without proper warnings"
((FFI_VIOLATIONS++))
((violations++))
fi
fi

if [ $violations -eq 0 ]; then
echo " ✅ Compliant" | tee -a "$LOG_FILE"
echo " ✅ Compliant"
((COMPLIANT_REPOS++))
else
echo " ❌ $violations violations found" | tee -a "$LOG_FILE"
echo " ❌ $violations violations found"
fi

((TOTAL_REPOS++))
echo "" | tee -a "$LOG_FILE"
echo ""
}

# Export function for parallel execution
export -f check_repo
export REPOS_BASE

# Find all repositories
echo "Scanning repositories in $REPOS_BASE..." | tee -a "$LOG_FILE"
echo "Scanning repositories in $REPOS_BASE..."

# Check core repositories first
for repo in hypatia gitbot-fleet ".git-private-farm"; do
Expand All @@ -96,27 +113,30 @@ find "$REPOS_BASE" -maxdepth 1 -type d ! -name "." ! -name ".." ! -name ".git*"
done

# Summary
echo "" | tee -a "$LOG_FILE"
echo "=== Summary ===" | tee -a "$LOG_FILE"
echo "Total repositories: $TOTAL_REPOS" | tee -a "$LOG_FILE"
echo "Compliant: $COMPLIANT_REPOS" | tee -a "$LOG_FILE"
echo "" | tee -a "$LOG_FILE"
echo "Violations:" | tee -a "$LOG_FILE"
echo " API (non-V): $API_VIOLATIONS" | tee -a "$LOG_FILE"
echo " ABI (non-Idris2): $ABI_VIOLATIONS" | tee -a "$LOG_FILE"
echo " FFI (non-Zig): $FFI_VIOLATIONS" | tee -a "$LOG_FILE"
echo "" | tee -a "$LOG_FILE"
echo ""
echo "=== Summary ==="
echo "Total repositories: $TOTAL_REPOS"
echo "Compliant: $COMPLIANT_REPOS"
echo ""
echo "Violations:"
echo " API (non-V): $API_VIOLATIONS"
echo " ABI (non-Idris2): $ABI_VIOLATIONS"
echo " FFI (non-Zig): $FFI_VIOLATIONS"
echo ""

COMPLIANCE_PERCENT=$(( (COMPLIANT_REPOS * 100) / TOTAL_REPOS ))
echo "Compliance: $COMPLIANCE_PERCENT%" | tee -a "$LOG_FILE"
echo "Compliance: $COMPLIANCE_PERCENT%"

if [ $COMPLIANCE_PERCENT -ge 90 ]; then
echo "Status: ✅ PASSING" | tee -a "$LOG_FILE"
echo "Status: ✅ PASSING"
exit 0
elif [ $COMPLIANCE_PERCENT -ge 70 ]; then
echo "Status: ⚠️ WARNING" | tee -a "$LOG_FILE"
echo "Status: ⚠️ WARNING"
exit 1
else
echo "Status: ❌ FAILING" | tee -a "$LOG_FILE"
echo "Status: ❌ FAILING"
exit 2
fi
fi

} | julia --startup-file=no --history-file=no \
"$SCRIPT_DIR/check-language-compliance-log.jl" "$STATE_DIR" "$(date +%Y%m%d).log"
Loading
Loading