A repo without git-daemon-export-ok, git's own flag, is private: only its owner, its collaborators and the names in forge/readers can clone it, fetch it, push to it or name it in an ssh command. To anyone else it is a repo that isn't there, byte for byte: forge shell resolves the path the way git does and, for a repo they can't read, runs git with an object directory that can't exist, so git answers as for a missing repo. forge repo new, ssh new and both imports take --private. ssh private, public and readers flip and share it the way Forgejo does: going private takes its pages, its place in the index, owner pages and repos.json, and its sites, previews and hostnames away, and the mirror's next sync deletes them from the bucket; going public first runs the secret check over its whole history and refuses unless --force. A private repo gets no pages, Jev asks nothing about it, and its sites stay unserved unless forge.publicsites is set; ls marks private repos and lists the ones you may read, and reviews reads its review logs.
- commit
- 9e09d033c64326660c1b101e332be4749cdddba4
- parent
- 07e5022d80f8999bf738e68cef85becd6432f41f
- author
- cat <admin@dev.site>
- kind
- feat risk score 2.5 of 3, Jev's estimate
| M | bin/ | 392 | ++++++++++++++++++++++++++---- |
| M | lib/ | 6 | +- |
| M | lib/ | 3 | +- |
| M | lib/ | 3 | +- |
| M | lib/ | 3 | + |
| M | test/ | 19 | + |
| A | test/ | 61 | +++++ |
| A | test/ | 62 | +++++ |
| A | test/ | 78 | ++++++ |
| A | test/ | 28 | ++ |
| A | test/ | 24 | ++ |
| A | test/ | 42 | +++ |
| A | test/ | 44 | +++ |
| A | test/ | 44 | +++ |
| A | test/ | 30 | ++ |
@@ -13,14 +13,14 @@
1313 # forge land --server [<id>|<branch>] the same, done by the forge itself (ssh land), no local checks1414 # forge site deploy [--here] [name...] build .forge/sites entries and publish them1515 # forge site push <dir> <name> publish a prebuilt directory16-# forge import [--to git@host] [--branches] <git-url> copy a repo into your forge account16+# forge import [--to git@host] [--branches] [--private] <git-url> copy a repo into your forge account1717 #1818 # Server commands run as the git user (see README.md):1919 # forge server init2020 # forge user add <name> <pubkey-file|key|-> (user rm <name> revokes keys)2121 # forge keys sync22-# forge repo new <owner>/<name> [description]23-# forge repo import <owner>/<name> <url> [description] (server-side fetch)22+# forge repo new [--private] <owner>/<name> [description]23+# forge repo import [--private] <owner>/<name> <url> [description] (server-side fetch)2424 # forge build [owner/repo]2525 # forge jev [owner/repo] ask Jev about new commits and reviews (needs TYPESAFE_API_KEY)2626 # forge metrics [n] [owner/repo] recent builds and where their time went@@ -513,19 +513,20 @@ cmd_site() {
513513 514514 # Copy a repo from anywhere git can clone into your forge account.515515 cmd_import() {516- to=$(git config --get forge.host || true) name= desc= branches=0516+ to=$(git config --get forge.host || true) name= desc= branches=0 private=517517 while [ $# -gt 0 ]; do518518 case $1 in519519 --to) to=$2; shift ;;520520 --name) name=$2; shift ;;521521 --desc) desc=$2; shift ;;522522 --branches) branches=1 ;;523+ --private) private=--private ;;523524 -*) die "import: unknown flag $1" ;;524525 *) break ;;525526 esac526527 shift527528 done528- [ $# -eq 1 ] || die "usage: forge import [--to git@host] [--name n] [--desc text] [--branches] <git-url>"529+ [ $# -eq 1 ] || die "usage: forge import [--to git@host] [--name n] [--desc text] [--branches] [--private] <git-url>"529530 [ -n "$to" ] || die "import: pass --to git@host or set git config --global forge.host git@host"530531 src=$1531532 [ -n "$name" ] || { name=${src%/}; name=${name%.git}; name=${name##*/}; name=${name##*:}; }@@ -534,7 +535,7 @@ cmd_import() {
534535 head=$(git --git-dir="$tmp/r.git" symbolic-ref --short HEAD 2>/dev/null || echo main)535536 # git-shell re-splits the command line, so quote the description for it.536537 q=$(printf '%s' "$desc" | sed "s/'/'\\\\''/g")537- out=$(${GIT_SSH_COMMAND:-ssh} "$to" new "$name" "'$q'" 2>&1) || case $out in538+ out=$(${GIT_SSH_COMMAND:-ssh} "$to" new $private "$name" "'$q'" 2>&1) || case $out in538539 *exists*) ;;539540 *) rm -rf "$tmp"; die "import: $out" ;;540541 esac@@ -542,6 +543,8 @@ cmd_import() {
542543 path=$(printf '%s\n' "$out" | sed -n 's/^git remote add origin [^:]*://p')543544 [ -n "$path" ] || path="$(${GIT_SSH_COMMAND:-ssh} "$to" whoami)/$name.git"544545 url=$to:$path546+ # Into a repo that was already there: make it private before anything lands.547+ case $out in *exists*) [ -z "$private" ] || ${GIT_SSH_COMMAND:-ssh} "$to" private "${path%.git}" >&2 || { rm -rf "$tmp"; die "import: could not make ${path%.git} private"; } ;; esac545548 if [ "$branches" = 1 ]; then set -- '+refs/heads/*:refs/heads/*'; else set -- "+refs/heads/$head:refs/heads/$head"; fi546549 git --git-dir="$tmp/r.git" push -q -o noreview -o "head=$head" "$url" "$@" '+refs/tags/*:refs/tags/*'547550 rm -rf "$tmp"@@ -800,7 +803,7 @@ cmd_server_init() {
800803 # FORGE_SITES_URL=/sites/{owner}/{repo}/{site}/801804 # FORGE_PREVIEWS_URL=/previews/{owner}/{repo}/{site}/{branch}/802805 EOF803- for c in new ls whoami signers site-url reviews comment approve approvals land help; do806+ for c in new ls whoami signers site-url reviews comment approve approvals land private public readers help; do804807 printf '#!/bin/sh\nexec "%s" shell-cmd %s "$@"\n' "$FORGE_BIN" "$c" >"$FORGE_ROOT/git-shell-commands/$c"805808 chmod +x "$FORGE_ROOT/git-shell-commands/$c"806809 done@@ -980,7 +983,7 @@ repo_setup() {
980983 # Hooks don't run on fetch, so branches don't become reviews.981984 repo_import() {982985 slug=$1 url=$2 desc=${3:-}983- cmd_repo new "$slug" "$desc"986+ cmd_repo new ${4:-} "$slug" "$desc"984987 dir=$FORGE_ROOT/$slug.git985988 git --git-dir="$dir" fetch -q "$url" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' || {986989 rm -rf "$dir"@@ -993,26 +996,45 @@ repo_import() {
993996 say "imported $url -> $slug ($(git --git-dir="$dir" for-each-ref refs/heads | wc -l | tr -d ' ') branches)"994997 }995998 999+# A repo is public while it has git-daemon-export-ok, git's own flag (git1000+# daemon and http-backend serve only repos with it; Forgejo toggles the same1001+# file). forge repo new adds it unless --private.9961002 cmd_repo() {997- if [ "${1:-}" = import ] && [ $# -ge 3 ]; then1003+ sub=${1:-}1004+ [ $# -gt 0 ] && shift1005+ private=1006+ [ "${1:-}" = --private ] && { private=--private; shift; }1007+ if [ "$sub" = import ] && [ $# -ge 2 ]; then9981008 load_config999- repo_import "$2" "$3" "${4:-}"1009+ repo_import "$1" "$2" "${3:-}" $private10001010 return10011011 fi1002- [ "${1:-}" = new ] && [ $# -ge 2 ] || die "usage: forge repo new <owner>/<name> [description] | repo import <owner>/<name> <url> [description]"1012+ [ "$sub" = new ] && [ $# -ge 1 ] || die "usage: forge repo new [--private] <owner>/<name> [description] | repo import [--private] <owner>/<name> <url> [description]"10031013 load_config1004- owner=${2%%/*} name=${2#*/}1005- valid_name "$owner" && valid_name "$name" && [ "$owner/$name" = "$2" ] || die "invalid repo: $2"1014+ owner=${1%%/*} name=${1#*/}1015+ valid_name "$owner" && valid_name "$name" && [ "$owner/$name" = "$1" ] || die "invalid repo: $1"10061016 dir=$FORGE_ROOT/$owner/$name.git10071017 [ -e "$dir" ] && die "$owner/$name exists"10081018 git init -q --bare "$dir"10091019 git --git-dir="$dir" symbolic-ref HEAD refs/heads/main1010- printf '%s\n' "${3:-}" >"$dir/description"1020+ printf '%s\n' "${2:-}" >"$dir/description"10111021 printf '%s\n' "$owner" >"$dir/owner"10121022 repo_setup "$dir"1013- touch "$dir/git-daemon-export-ok"1023+ [ -n "$private" ] || touch "$dir/git-daemon-export-ok"10141024 git --git-dir="$dir" update-server-info1015- say "created $owner/$name"1025+ say "created $owner/$name${private:+ (private)}"1026+}1027+1028+# can_read <git-dir> <user>: anyone may read a public repo; a private one,1029+# its owner and the names in its forge/collaborators and forge/readers.1030+can_read() {1031+ [ -f "$1/git-daemon-export-ok" ] && return 01032+ [ -n "$2" ] || return 11033+ [ "$2" = "$(basename "$(dirname "$1")")" ] && return 01034+ for f in "$1/forge/collaborators" "$1/forge/readers"; do1035+ if [ -f "$f" ] && grep -qxF -- "$2" "$f"; then return 0; fi1036+ done1037+ return 110161038 }10171039 10181040 cmd_shell() {@@ -1024,22 +1046,113 @@ cmd_shell() {
10241046 exit 010251047 fi10261048 cd "$FORGE_ROOT"1027- push_create1049+ git_cmd1050+ # A private repo you can't read looks exactly like one that isn't there.1051+ if [ -n "$GSERVICE" ] && hidden_repo "$GPATH"; then1052+ [ "$GSERVICE" != receive-pack ] || push_create 11053+ no_such_repo1054+ fi1055+ [ "$GSERVICE" != receive-pack ] || push_create10281056 exec git-shell -c "$SSH_ORIGINAL_COMMAND"10291057 }10301058 1059+# The service and repo path of a git command over ssh, read the way git-shell1060+# reads them (git's shell.c, and sq_dequote in quote.c): "git-upload-pack 'p'"1061+# or "git upload-pack 'p'", for upload-pack, upload-archive and receive-pack,1062+# with one single-quoted argument ('\'' and '\!' inside). Sets GSERVICE and1063+# GPATH; both are empty for anything else, which git-shell runs or refuses1064+# itself.1065+git_cmd() {1066+ GSERVICE= GPATH=1067+ c=$SSH_ORIGINAL_COMMAND1068+ case $c in git[[:space:]]*) c=git-${c#git?} ;; esac1069+ case $c in1070+ "git-upload-pack "* | "git-upload-archive "* | "git-receive-pack "*) ;;1071+ *) return 0 ;;1072+ esac1073+ svc=${c%% *} s=${c#* } gp=1074+ case $s in "'"*) s=${s#?} ;; *) return 0 ;; esac1075+ while :; do1076+ case $s in *"'"*) ;; *) return 0 ;; esac1077+ gp=$gp${s%%"'"*}1078+ s=${s#*"'"}1079+ case $s in1080+ '') break ;;1081+ "\\''"*) gp="$gp'" s=${s#???} ;;1082+ "\\!'"*) gp="$gp!" s=${s#???} ;;1083+ *) return 0 ;;1084+ esac1085+ done1086+ case $gp in -*) return 0 ;; esac1087+ GSERVICE=${svc#git-} GPATH=$gp1088+}1089+1090+# hidden_repo <path>: does a git path name a private repo $user can't read?1091+# The path is looked up the way git's enter_repo does it (trailing slashes1092+# off, a leading ~ or ~user expanded, then the suffixes /.git, "", .git/.git1093+# and .git, any of which may be a "gitdir:" file) and resolved physically,1094+# so no spelling of it (.., //, symlinks, an absolute path) gets around the1095+# check. Any candidate that lands in such a repo counts. Run in FORGE_ROOT.1096+hidden_repo() {1097+ hp=$11098+ while [ "${#hp}" -gt 1 ] && [ "${hp%/}" != "$hp" ]; do hp=${hp%/}; done1099+ case $hp in1100+ '~' | '~/'*) hp=$HOME${hp#?} ;;1101+ '~'*)1102+ hu=${hp%%/*}1103+ hu=${hu#?}1104+ case $hu in *[!A-Za-z0-9._-]*) return 1 ;; esac1105+ eval "hh=~$hu"1106+ [ "$hh" != "~$hu" ] || return 11107+ hp=$hh${hp#"~$hu"}1108+ ;;1109+ esac1110+ hr=$(pwd -P)1111+ for sfx in /.git '' .git/.git .git; do1112+ hc=$hp$sfx1113+ if [ -f "$hc" ]; then1114+ hg=$(sed -n '1s/^gitdir: //p' "$hc" 2>/dev/null) || continue1115+ [ -n "$hg" ] || continue1116+ case $hg in /*) ;; *) hg=$(dirname -- "$hc")/$hg ;; esac1117+ hc=$hg1118+ fi1119+ [ -d "$hc" ] || continue1120+ hd=$(CDPATH= cd -P -- "$hc" 2>/dev/null && pwd -P) || continue1121+ case $hd in "$hr"/*/*) ;; *) continue ;; esac1122+ hd=${hd#"$hr"/}1123+ ho=${hd%%/*} hd=${hd#*/}1124+ hn=${hd%%/*}1125+ case $hn in *.git) ;; *) continue ;; esac1126+ [ -f "$hr/$ho/$hn/HEAD" ] || continue1127+ can_read "$hr/$ho/$hn" "$user" || return 01128+ done1129+ return 11130+}1131+1132+# Run the command so that git finds no repo at the path and says so in its1133+# own words, which for upload-archive is a sideband exchange: byte for byte1134+# what a missing repo gets. git's is_git_directory honors1135+# GIT_OBJECT_DIRECTORY, and this one can't exist, since keys is a file.1136+no_such_repo() {1137+ GIT_OBJECT_DIRECTORY=$FORGE_STATE/keys/none exec git-shell -c "$SSH_ORIGINAL_COMMAND"1138+}1139+10311140 # A push to <user>/<name>.git that doesn't exist yet creates it first, the10321141 # way ssh <host> new <name> does, and the push goes on into it. Anything1033-# else that names a missing repo is left to git-shell, which says so.1142+# else that names a missing repo is left to git-shell, which says so. With1143+# 1, the repo is there but private and not yours to read: refuse it the way1144+# a missing one under someone else's name is refused.10341145 push_create() {1035- case $SSH_ORIGINAL_COMMAND in1036- "git-receive-pack '"*"'" | "git receive-pack '"*"'") ;;1037- *) return 0 ;;1038- esac1039- p=${SSH_ORIGINAL_COMMAND#*"'"}1040- p=${p%"'"}1041- p=${p%/}1146+ p=${GPATH%/}10421147 p=${p%.git}1148+ if [ "${1:-0}" = 1 ]; then1149+ case $p in1150+ # Only a path that isn't a plain name of yours (dami/../ana/x) gets here.1151+ "$user"/*) die "invalid repo: $p" ;;1152+ */*) valid_name "${p%%/*}" && die "no repo $p; only ${p%%/*} can create repos under ${p%%/*}/" ;;1153+ esac1154+ return 01155+ fi10431156 [ ! -e "$p.git" ] && [ ! -e "$p" ] || return 010441157 case $p in10451158 "$user"/*) ;;@@ -1061,13 +1174,23 @@ branch_review() {
10611174 grep -lxF "branch $2" "$1"/* 2>/dev/null | while read -r x; do grep -q '^deleted ' "$x" || echo "$x"; done | tail -n 110621175 }10631176 1064-# open_repo owner/repo: set RDIR, or die.1177+# open_repo owner/repo: set RDIR, or die. A private repo $user can't read1178+# isn't there, as far as they can tell.10651179 open_repo() {10661180 o=${1%%/*} n=${1#*/}1067- valid_name "$o" && valid_name "$n" && [ "$o/$n" = "$1" ] && [ -f "$FORGE_ROOT/$1.git/HEAD" ] || die "no repo $1"1181+ valid_name "$o" && valid_name "$n" && [ "$o/$n" = "$1" ] && [ -f "$FORGE_ROOT/$1.git/HEAD" ] &&1182+ can_read "$FORGE_ROOT/$1.git" "$user" || die "no repo $1"10681183 RDIR=$FORGE_ROOT/$1.git10691184 }10701185 1186+# open_admin owner/repo: open_repo, for its owner and collaborators only1187+# (repo_ctx's ADMIN).1188+open_admin() {1189+ open_repo "$1"1190+ [ "$user" = "${1%%/*}" ] || grep -qxF -- "$user" "$RDIR/forge/collaborators" 2>/dev/null ||1191+ die "only ${1%%/*} and collaborators of $1 can do that"1192+}1193+10711194 # open_review owner/repo id|branch: set RDIR RF (the review log) RID RBRANCH10721195 # RAUTHOR RTIP RLIVE, or die. A branch names its open review.10731196 open_review() {@@ -1102,31 +1225,38 @@ approvers() {
11021225 11031226 # rebuild <owner>/<repo> <reason>: refresh a repo's pages after a change, in11041227 # the background unless FORGE_SYNC_BUILD (or not at all with FORGE_NO_BUILD).1105-# Jev's answers are asked for first, so the build can show them.1228+# Jev's answers are asked for first, so the build can show them. "-" is the1229+# index and owner pages only.11061230 rebuild() {11071231 if [ "${FORGE_NO_BUILD:-0}" = 1 ]; then11081232 :11091233 elif [ "${FORGE_SYNC_BUILD:-0}" = 1 ]; then1110- "$FORGE_BIN" jev "$1" >&21234+ [ "$1" = - ] || "$FORGE_BIN" jev "$1" >&211111235 FORGE_BUILD_REASON=$2 "$FORGE_BIN" build "$1" >&211121236 else1113- (cd / && FORGE_BUILD_REASON=$2 nohup sh -c '"$0" jev "$1"; exec "$0" build "$1"' "$FORGE_BIN" "$1" </dev/null >>"$FORGE_STATE/build.log" 2>&1 &)1237+ (cd / && FORGE_BUILD_REASON=$2 nohup sh -c '[ "$1" = - ] || "$0" jev "$1"; exec "$0" build "$1"' "$FORGE_BIN" "$1" </dev/null >>"$FORGE_STATE/build.log" 2>&1 &)11141238 fi11151239 }11161240 11171241 # forge jev [owner/repo]: ask Jev about what the repo's pages show and store11181242 # the answers (lib/jev-fill.ts). Without TYPESAFE_API_KEY it does nothing,1119-# and a failure never stops the build that follows.1243+# and a failure never stops the build that follows. Private repos have no1244+# pages, so nothing is asked about them.11201245 cmd_jev() {11211246 load_config11221247 [ -n "${TYPESAFE_API_KEY:-}" ] && [ "${FORGE_JEV:-1}" != 0 ] || return 01248+ [ $# -eq 0 ] || [ ! -f "$FORGE_ROOT/$1.git/HEAD" ] || [ -f "$FORGE_ROOT/$1.git/git-daemon-export-ok" ] || return 011231249 node "$FORGE_HOME/lib/jev-fill.ts" "$@" || say "jev: failed for ${1:-all repos}"11241250 }11251251 1252+# ev_private <git-dir>: the field that marks an event of a private repo1253+# (lib/events-server.ts streams none of them).1254+ev_private() { [ -f "$1/git-daemon-export-ok" ] || printf ',"private":true'; }1255+11261256 # After a comment or approval: tell open pages, and rebuild the repo the way11271257 # post-receive does after a push.11281258 review_changed() {1129- printf '{"t":%s000,"repo":"%s","type":"%s","user":"%s","review":%s}\n' "$(date +%s)" "$1" "$2" "$user" "$RID" >>"$FORGE_STATE/events.jsonl"1259+ printf '{"t":%s000,"repo":"%s","type":"%s","user":"%s","review":%s%s}\n' "$(date +%s)" "$1" "$2" "$user" "$RID" "$(ev_private "$RDIR")" >>"$FORGE_STATE/events.jsonl"11301260 rebuild "$1" "$2"11311261 }11321262 @@ -1265,6 +1395,116 @@ sc_land() {
12651395 echo "landed #$id on $onto at $(git rev-parse --short "$tip"); deleted $gone"12661396 }12671397 1398+# ---------------------------------------------------------------- visibility (server)1399+# A repo is private when it has no git-daemon-export-ok. Flipping it works1400+# the way Forgejo's does: going private takes away everything public about1401+# the repo, and going public brings it back. Nobody can recall clones.1402+1403+# sites_public <git-dir>: are this repo's sites and previews served? Those of1404+# a public repo are, and a private repo's with git config forge.publicsites true.1405+sites_public() {1406+ [ -f "$1/git-daemon-export-ok" ] || [ "$(git --git-dir="$1" config --bool forge.publicsites || echo false)" = true ]1407+}1408+1409+# Link every site and preview the repo has refs for. Runs in a repo1410+# (GIT_DIR) with OWNER and NAME set.1411+redeploy_sites() {1412+ for ref in $(git for-each-ref --format='%(refname)' refs/sites/ refs/site-previews/); do1413+ case $ref in1414+ refs/sites/*) deploy_site "sites/$OWNER/$NAME/${ref#refs/sites/}" "$ref" ;;1415+ *)1416+ rest=${ref#refs/site-previews/}1417+ deploy_site "previews/$OWNER/$NAME/${rest%%/*}/$(enc_ref "${rest#*/}")" "$ref"1418+ ;;1419+ esac1420+ done1421+}1422+1423+# private <owner>/<repo>: only its owner, collaborators and readers can see it1424+# from now on. Its pages go, the index, owner pages and repos.json are1425+# rebuilt without it, its sites, previews and their hostnames go unless1426+# forge.publicsites, and the mirror's next sync deletes them from the bucket.1427+# The events server stops streaming it at once (it checks the flag).1428+sc_private() {1429+ [ $# -eq 1 ] || die "usage: private <owner>/<repo>"1430+ open_admin "$1"1431+ [ -f "$RDIR/git-daemon-export-ok" ] || { echo "$1 is already private"; return 0; }1432+ rm -f "$RDIR/git-daemon-export-ok"1433+ rm -rf "${FORGE_WWW:?}/$1"1434+ if ! sites_public "$RDIR"; then1435+ for l in "$FORGE_WWW/sites/$1"/* "$FORGE_WWW/previews/$1"/*/*; do1436+ if [ -L "$l" ]; then deploy_site "${l#"$FORGE_WWW"/}" "$ZERO"; fi1437+ done1438+ rm -rf "${FORGE_WWW:?}/sites/$1" "${FORGE_WWW:?}/previews/$1"1439+ fi1440+ rebuild - private1441+ mirror_kick1442+ echo "$1 is private: only ${1%%/*}, its collaborators and its readers (readers $1 add <user>) can clone it."1443+ echo "Its pages and sites are gone from here, and from the mirror at its next sync. Clones and copies people already have can't be recalled."1444+}1445+1446+# public <owner>/<repo> [--force]: anyone can clone it and its pages come1447+# back. All of its history becomes public at once, so the secret check reads1448+# every commit on its branches and tags first, not only what a push adds,1449+# and refuses unless --force.1450+sc_public() {1451+ force=01452+ case $#:${2:-} in 1:) ;; 2:--force) force=1 ;; *) die "usage: public <owner>/<repo> [--force]" ;; esac1453+ open_admin "$1"1454+ [ ! -f "$RDIR/git-daemon-export-ok" ] || { echo "$1 is already public"; return 0; }1455+ rc=01456+ GIT_DIR=$RDIR node "$FORGE_HOME/lib/jev-secrets.ts" --history </dev/null || rc=$?1457+ case $rc in1458+ 0) ;;1459+ 3)1460+ [ "$force" = 1 ] || die "$1 stays private: its history has what looks like a credential (above); take it out of every commit that adds it and force-push, or: public $1 --force"1461+ say "forge: making $1 public anyway (--force)"1462+ ;;1463+ *)1464+ [ "$force" = 1 ] || die "$1 stays private: the secret check did not run; try again, or: public $1 --force"1465+ say "forge: the secret check did not run; making $1 public anyway (--force)"1466+ ;;1467+ esac1468+ touch "$RDIR/git-daemon-export-ok"1469+ (1470+ export GIT_DIR="$RDIR"1471+ OWNER=${1%%/*} NAME=${1#*/}1472+ redeploy_sites1473+ )1474+ rebuild "$1" public1475+ echo "$1 is public: anyone can clone it, and its pages and sites are back once the build finishes."1476+}1477+1478+# readers <owner>/<repo> [add|rm <user>]: who may read it while it is1479+# private, besides its owner and collaborators; forge/readers, one name a line.1480+sc_readers() {1481+ [ $# -eq 1 ] || [ $# -eq 3 ] || die "usage: readers <owner>/<repo> [add|rm <user>]"1482+ open_admin "$1"1483+ f=$RDIR/forge/readers1484+ if [ $# -eq 1 ]; then1485+ if [ -f "$f" ]; then cat "$f"; fi1486+ return 01487+ fi1488+ who=$31489+ case $2 in1490+ add)1491+ valid_name "$who" && awk -v w="$who" '$1 == w { f = 1 } END { exit !f }' "$FORGE_STATE/keys" || die "no user $who"1492+ if [ -f "$f" ] && grep -qxF -- "$who" "$f"; then echo "$who already reads $1"; return 0; fi1493+ mkdir -p "$RDIR/forge"1494+ printf '%s\n' "$who" >>"$f"1495+ if [ -f "$RDIR/git-daemon-export-ok" ]; then echo "$who reads $1 (anyone can while it is public; this counts once it is private)"1496+ else echo "$who can read $1"; fi1497+ ;;1498+ rm)1499+ [ -f "$f" ] && grep -qxF -- "$who" "$f" || die "$who is not a reader of $1"1500+ grep -vxF -- "$who" "$f" >"$f.tmp" || true1501+ mv "$f.tmp" "$f"1502+ echo "$who no longer reads $1 (a clone they have stays theirs)"1503+ ;;1504+ *) die "usage: readers <owner>/<repo> [add|rm <user>]" ;;1505+ esac1506+}1507+12681508 # Commands reachable over ssh through git-shell-commands/.12691509 cmd_shell_cmd() {12701510 load_config@@ -1273,21 +1513,43 @@ cmd_shell_cmd() {
12731513 shift12741514 case $c in12751515 new)1276- [ $# -ge 1 ] || die "usage: ssh <host> new <name> [description]"1516+ private=1517+ [ "${1:-}" = --private ] && { private=--private; shift; }1518+ [ $# -ge 1 ] || die "usage: ssh <host> new [--private] <name> [description]"12771519 n=$112781520 shift1279- cmd_repo new "$user/$n" "$*"1521+ cmd_repo new $private "$user/$n" "$*"12801522 echo "git remote add origin ${FORGE_SSH:-git@host}:$user/$n.git"12811523 ;;1282- ls) for d in "$FORGE_ROOT/$user"/*.git; do [ -d "$d" ] && basename "$d" .git; done | sort -f ;;1524+ ls)1525+ for d in "$FORGE_ROOT/$user"/*.git; do1526+ [ -d "$d" ] || continue1527+ if [ -f "$d/git-daemon-export-ok" ]; then basename "$d" .git; else echo "$(basename "$d" .git) (private)"; fi1528+ done | sort -f1529+ # Other people's private repos you may read.1530+ for d in "$FORGE_ROOT"/*/*.git; do1531+ [ -f "$d/HEAD" ] && [ ! -f "$d/git-daemon-export-ok" ] || continue1532+ o=$(basename "$(dirname "$d")")1533+ if [ "$o" != "$user" ] && can_read "$d" "$user"; then echo "$o/$(basename "$d" .git) (private)"; fi1534+ done | sort -f1535+ ;;12831536 whoami) echo "$user" ;;12841537 signers) cat "$FORGE_STATE/allowed_signers" ;;1285- site-url) node "$FORGE_HOME/lib/hosts.ts" url "$@" ;;1538+ site-url)1539+ [ $# -ge 2 ] || die "usage: site-url <owner>/<repo> <site> [branch]"1540+ open_repo "$1"1541+ node "$FORGE_HOME/lib/hosts.ts" url "$@"1542+ ;;12861543 reviews)12871544 [ $# -ge 1 ] || die "usage: reviews <owner>/<repo> [--tsv]"12881545 open_repo "$1"1289- [ -f "$FORGE_WWW/$1/reviews.json" ] || die "no review list for $1 yet; it is written by the page build"12901546 case ${2:-} in '' | --tsv) ;; *) die "usage: reviews <owner>/<repo> [--tsv]" ;; esac1547+ # A private repo has no pages: its list comes from the review logs, without CI.1548+ if [ ! -f "$RDIR/git-daemon-export-ok" ]; then1549+ node "$FORGE_HOME/lib/review-list.ts" --repo "$RDIR" ${2:+"$2"}1550+ return1551+ fi1552+ [ -f "$FORGE_WWW/$1/reviews.json" ] || die "no review list for $1 yet; it is written by the page build"12911553 node "$FORGE_HOME/lib/review-list.ts" "$FORGE_WWW/$1/reviews.json" ${2:+"$2"}12921554 ;;12931555 comment) sc_comment "$@" ;;@@ -1300,7 +1562,10 @@ cmd_shell_cmd() {
13001562 if [ "$(git --git-dir="$RDIR" config --bool forge.requireapproval || echo false)" = true ]; then echo required; else echo optional; fi13011563 approvers "$RDIR" "$2"13021564 ;;1303- *) printf 'commands:\n new <name> [description] create %s/<name>\n ls list your repos\n signers keys that may sign CI results (for forge verify)\n site-url <owner>/<repo> <site> [branch] where a site deploy is served\n reviews <owner>/<repo> open and draft reviews (forge reviews)\n land <owner>/<repo> <id|branch> land a review here, with the same gates as a push\n comment, approve, approvals used by forge comment, approve and land\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user" ;;1565+ private) sc_private "$@" ;;1566+ public) sc_public "$@" ;;1567+ readers) sc_readers "$@" ;;1568+ *) printf 'commands:\n new [--private] <name> [description] create %s/<name>\n ls list your repos, and private ones you may read\n signers keys that may sign CI results (for forge verify)\n site-url <owner>/<repo> <site> [branch] where a site deploy is served\n reviews <owner>/<repo> open and draft reviews (forge reviews)\n land <owner>/<repo> <id|branch> land a review here, with the same gates as a push\n comment, approve, approvals used by forge comment, approve and land\n private <owner>/<repo> only its owner, collaborators and readers can see it\n public <owner>/<repo> [--force] anyone can, once its whole history passes the secret check\n readers <owner>/<repo> [add|rm <user>] who may read it while it is private\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user" ;;13041569 esac13051570 }13061571 @@ -1320,9 +1585,11 @@ repo_ctx() {
13201585 13211586 deny() { printf 'forge: %s\n' "$*" >&2; exit 1; }13221587 1323-# Everything here is public, so refuse a push that adds a credential. The1324-# script prints what it found and exits 3 only then; any other failure of the1325-# check (node exits 1 on a broken import) lets the push through.1588+# Refuse a push that adds a credential: a public repo shows it to everyone,1589+# and a private one keeps it in its history for whoever reads it, or for1590+# everyone once it is made public. The script prints what it found and exits1591+# 3 only then; any other failure of the check (node exits 1 on a broken1592+# import) lets the push through.13261593 scan_secrets() {13271594 printf '%s\n' "$updates" | node "$FORGE_HOME/lib/jev-secrets.ts" && return 013281595 [ $? != 3 ] || deny "push refused by the secret check"@@ -1331,6 +1598,9 @@ scan_secrets() {
13311598 13321599 hook_pre_receive() {13331600 repo_ctx1601+ # forge shell turns away anyone who can't read a private repo; this is in1602+ # case something else let them through.1603+ [ -z "$USER_" ] || can_read "$GIT_DIR" "$USER_" || deny "no repo $OWNER/$NAME"13341604 # The default branch first, so a refused push explains that one first.13351605 updates=$(awk -v d="refs/heads/$DEFAULT" '$3 == d { print; next } { r = r $0 "\n" } END { printf "%s", r }')13361606 notes_new=@@ -1482,6 +1752,16 @@ drop_previews() {
14821752 done14831753 }14841754 1755+# push_site <target> <sha>: deploy_site after a push. A private repo's sites1756+# stay unserved unless forge.publicsites; the refs keep them for later.1757+push_site() {1758+ if [ "$2" = "$ZERO" ] || sites_public "$GIT_DIR"; then1759+ deploy_site "$1" "$2"1760+ else1761+ say "site ${1#*/*/*/}: not served, since $OWNER/$NAME is private (git config forge.publicsites true serves its sites)"1762+ fi1763+}1764+14851765 hook_post_receive() {14861766 repo_ctx14871767 # FORGE_NOW pins the clock for review logs; the demo and tests set it.@@ -1524,10 +1804,10 @@ noreview
15241804 case $ref in15251805 "refs/heads/$DEFAULT") ;;15261806 refs/heads/*) [ "$noreview" = 1 ] || record_review "${ref#refs/heads/}" "$new" ;;1527- refs/sites/*) deploy_site "sites/$OWNER/$NAME/${ref#refs/sites/}" "$new" ;;1807+ refs/sites/*) push_site "sites/$OWNER/$NAME/${ref#refs/sites/}" "$new" ;;15281808 refs/site-previews/*)15291809 rest=${ref#refs/site-previews/}1530- deploy_site "previews/$OWNER/$NAME/${rest%%/*}/$(enc_ref "${rest#*/}")" "$new"1810+ push_site "previews/$OWNER/$NAME/${rest%%/*}/$(enc_ref "${rest#*/}")" "$new"15311811 ;;15321812 esac15331813 done <<-EOF@@ -1535,7 +1815,7 @@ noreview
15351815 EOF15361816 git update-server-info15371817 refs=$(printf '%s' "$PUSHED" | tr -d '"\\')1538- printf '{"t":%s000,"repo":"%s","type":"push","user":"%s","refs":"%s"}\n' "$(date +%s)" "$OWNER/$NAME" "${USER_:-admin}" "$refs" >>"$FORGE_STATE/events.jsonl"1818+ printf '{"t":%s000,"repo":"%s","type":"push","user":"%s","refs":"%s"%s}\n' "$(date +%s)" "$OWNER/$NAME" "${USER_:-admin}" "$refs" "$(ev_private "$GIT_DIR")" >>"$FORGE_STATE/events.jsonl"15391819 rebuild "$OWNER/$NAME" push15401820 }15411821 @@ -1558,7 +1838,9 @@ write_graph() {
15581838 }15591839 15601840 # Without arguments, rebuild repo by repo so memory stays bounded by the1561-# largest repo, then drop pages for repos and owners that no longer exist.1841+# largest repo, then drop pages for repos and owners that no longer exist,1842+# and for repos that are private now (and their sites, unless1843+# forge.publicsites), so a flag removed by hand takes effect here too.15621844 build_all() {15631845 # Same order as the pages list them: ignoring case.15641846 repos=$(cd "$FORGE_ROOT" && for d in */*.git; do [ -f "$d/HEAD" ] && printf '%s\n' "${d%.git}"; done | sort -f)@@ -1570,7 +1852,13 @@ build_all() {
15701852 slug=${w#"$FORGE_WWW"/}15711853 slug=${slug%/}15721854 case $slug in assets/* | sites/* | previews/* | hosts/*) continue ;; esac1573- [ -f "$FORGE_ROOT/$slug.git/HEAD" ] || rm -rf "$w"1855+ [ -f "$FORGE_ROOT/$slug.git/HEAD" ] && [ -f "$FORGE_ROOT/$slug.git/git-daemon-export-ok" ] || rm -rf "$w"1856+ done1857+ for w in "$FORGE_WWW"/sites/*/*/ "$FORGE_WWW"/previews/*/*/; do1858+ [ -d "$w" ] || continue1859+ slug=${w#"$FORGE_WWW"/*/}1860+ slug=${slug%/}1861+ [ ! -f "$FORGE_ROOT/$slug.git/HEAD" ] || sites_public "$FORGE_ROOT/$slug.git" || rm -rf "$w"15741862 done15751863 for w in "$FORGE_WWW"/*/; do15761864 b=$(basename "$w")@@ -1584,6 +1872,14 @@ cmd_build() {
15841872 load_config15851873 [ $# -gt 0 ] || { build_all; return; }15861874 only=$11875+ # A private repo gets no pages, only its commit-graph. One that still has1876+ # pages (made private by hand, say) is built: the build leaves it out, so1877+ # its pages go and the index and owner pages are redone without it.1878+ if [ "$only" != - ] && [ -f "$FORGE_ROOT/$only.git/HEAD" ] && [ ! -f "$FORGE_ROOT/$only.git/git-daemon-export-ok" ] && [ ! -e "$FORGE_WWW/$only" ]; then1879+ write_graph "$FORGE_ROOT/$only.git"1880+ say "$only is private: no pages to build"1881+ return 01882+ fi15871883 lock=$FORGE_STATE/build.lock15881884 n=015891885 until mkdir "$lock" 2>/dev/null; do@@ -1616,6 +1912,10 @@ cmd_build() {
16161912 swap "$e" "$FORGE_WWW/$b"16171913 fi16181914 done1915+ # Made private while this was building: its pages go after all.1916+ if [ "$only" != - ] && [ -f "$FORGE_ROOT/$only.git/HEAD" ] && [ ! -f "$FORGE_ROOT/$only.git/git-daemon-export-ok" ]; then1917+ rm -rf "${FORGE_WWW:?}/$only"1918+ fi16191919 if [ -f "$FORGE_STATE/events.jsonl" ] && [ "$(wc -c <"$FORGE_STATE/events.jsonl")" -gt 1048576 ]; then16201920 tail -n 1000 "$FORGE_STATE/events.jsonl" >"$FORGE_STATE/events.jsonl.tmp" && mv "$FORGE_STATE/events.jsonl.tmp" "$FORGE_STATE/events.jsonl"16211921 fi@@ -53,6 +53,7 @@ const caseless = (a: string, b: string): number => a.localeCompare(b, 'en', { se
5353 // Top-level names used by the forge itself; never owners.5454 const RESERVED = new Set(['assets', 'sites', 'previews', 'hosts', 'index.html', 'git-shell-commands', 'lost+found']);5555 56+// Every repo, public or not (RepoSummary.private); builds filter.5657 export function discover(cfg: Config): { repos: RepoSummary[]; owners: string[] } {5758 if (!fs.existsSync(cfg.root)) return { repos: [], owners: [] };5859 const repos: RepoSummary[] = [];@@ -139,7 +140,10 @@ const ciKey = (ci: CiStatus | null): string => (ci ? `${ci.state}:${ci.latest.st
139140 export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {140141 build.dataStart = performance.now();141142 const cfg = config(env);142- const { repos: summaries, owners: ownerNames } = discover(cfg);143+ const { repos: all, owners: ownerNames } = discover(cfg);144+ // Private repos have no public pages: none are built, and the index, owner145+ // pages and repos.json leave them out.146+ const summaries = all.filter((s) => !s.private);143147 const verifier = new Verifier({ allowedSigners: cfg.allowedSigners, cacheFile: path.join(cfg.cacheDir, 'verify.json') });144148 // Index and owner pages list every repo, but a single-repo build only loads145149 // one. Each repo's counts and sites are cached when it is built.@@ -47,7 +47,8 @@ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import
4747 if (!enabled()) process.exit(0);4848 const cfg = config();4949 const only = process.argv[2];50- const targets = only ? [only] : discover(cfg).repos.map((r) => `${r.owner}/${r.name}`);50+ // Private repos have no pages to show answers on (bin/forge's cmd_jev skips them too).51+ const targets = only ? [only] : discover(cfg).repos.filter((r) => !r.private).map((r) => `${r.owner}/${r.name}`);5152 const t0 = Date.now();5253 for (const slug of targets) {5354 const [owner, name] = slug.split('/');@@ -38,7 +38,8 @@ export function summarize(cfg: Config, owner: string, name: string): RepoSummary
3838 if (description.startsWith('Unnamed repository')) description = '';3939 const head = tryRun(dir, ['symbolic-ref', '--short', 'HEAD'])?.trim() || 'main';4040 const last = tryRun(dir, ['for-each-ref', '--sort=-committerdate', '--count=1', '--format=%(committerdate:iso-strict)', 'refs/heads'])?.trim() || '';41- return { owner, name, dir, description, defaultBranch: head, lastCommit: last, slug: `${owner}/${name}`, urls: repoUrls(cfg, owner, name) };41+ const isPrivate = !fs.existsSync(path.join(dir, 'git-daemon-export-ok'));42+ return { owner, name, dir, description, defaultBranch: head, lastCommit: last, slug: `${owner}/${name}`, urls: repoUrls(cfg, owner, name), private: isPrivate };4243 }4344 4445 // Record markers carry a per-build nonce: patches can contain any byte,@@ -327,6 +327,9 @@ export interface RepoSummary {
327327 lastCommit: string;328328 slug: string;329329 urls: RepoUrls;330+ // No git-daemon-export-ok: only its owner, collaborators and readers may331+ // read it, and the public build leaves it out.332+ private: boolean;330333 counts?: RepoCounts;331334 sites?: SiteLink[];332335 // What the repo is, by Jev (lib/jev-commits.ts).@@ -13,6 +13,10 @@
1313 # ssh_as ana land dami/greet 2 what `ssh git@host land ...` runs for ana1414 # clone_ssh ana dami/greet w clone over the forced command, as ana1515 # build [dami/greet] write pages (11ty) into $FORGE_WWW16+# repo --private dami/secret a private repo (no git-daemon-export-ok)17+# site_commit w "text" a commit of a one-page site, to push to refs/sites/...18+# aside dami/secret ... back dami/secret move a repo out of the forge and back19+# sync_builds hooks and ssh commands build pages before returning1620 #1721 # Assertions print what failed and stop the scenario:1822 # ok cmd... cmd succeeds fails cmd... cmd fails@@ -162,3 +166,18 @@ push_ssh() {
162166 }163167 164168 build() { "$FORGE" build "$@" >"$T_LOG" 2>&1 || fail "build ${*:-all} failed"; }169+170+# site_commit dir text: a commit whose tree is one index.html, as forge site171+# push makes them.172+site_commit() {173+ (cd "$1" && b=$(printf '<p>%s</p>\n' "$2" | git hash-object -w --stdin) &&174+ t=$(printf '100644 blob %s\tindex.html\n' "$b" | git mktree) && git commit-tree -m "site: $2" "$t")175+}176+177+# aside slug, back slug: take a repo out of the forge and put it back, so a178+# scenario can see what a missing repo of the same name looks like.179+aside() { mv "$FORGE_ROOT/$1.git" "$T/aside.git"; }180+back() { mv "$T/aside.git" "$FORGE_ROOT/$1.git"; }181+182+# From here on, what hooks and ssh commands rebuild is built before they return.183+sync_builds() { export FORGE_NO_BUILD=0 FORGE_SYNC_BUILD=1; }@@ -0,0 +1,61 @@
1+# A private repo over ssh: its owner, collaborators and readers clone and2+# fetch it; anyone else gets exactly what a missing repo gives, byte for3+# byte, however the path is spelled.4+. "$(dirname "$0")/../lib.sh"5+user dami6+user ana7+user bob8+user cat9+repo --private dami/secret10+missing "$FORGE_ROOT/dami/secret.git/git-daemon-export-ok"11+clone dami dami/secret "$T/w/dami"12+at "$T/w/dami" commit "first"13+ok push_as dami "$T/w/dami" origin main14+mkdir "$FORGE_ROOT/dami/secret.git/forge"15+printf 'ana\n' >"$FORGE_ROOT/dami/secret.git/forge/collaborators"16+printf 'bob\n' >"$FORGE_ROOT/dami/secret.git/forge/readers"17+18+ok clone_ssh dami dami/secret "$T/w/dami2"19+ok clone_ssh ana dami/secret "$T/w/ana"20+ok clone_ssh bob dami/secret "$T/w/bob"21+has "$T/w/bob/file1.txt" '^first'22+fails clone_ssh cat dami/secret "$T/w/cat"23+missing "$T/w/cat"24+25+# Every way of reaching the repo, as cat, against the same path with the26+# repo gone. upload-archive (git archive --remote) and both command forms too.27+ln -s ../dami/secret.git "$FORGE_ROOT/cat/link.git"28+for cmd in "git-upload-pack 'dami/secret.git'" "git upload-pack 'dami/secret'" "git-upload-pack 'dami/secret.git/'" \29+ "git-upload-pack 'dami/../dami/secret.git'" "git-upload-pack '$FORGE_ROOT/dami/secret.git'" \30+ "git-upload-pack '~/dami/secret.git'" "git-upload-pack 'cat/link.git'" "git-upload-archive 'dami/secret.git'" \31+ "git upload-pack 'dami/secret.git'" "git-upload-pack 'dami/'\\''secret.git'"; do32+ fails ssh_as cat "$cmd" </dev/null33+ denied=$(output)34+ aside dami/secret35+ fails ssh_as cat "$cmd" </dev/null36+ back dami/secret37+ eq "$denied" "$(output)"38+done39+has "$T_LOG" "'dami/'secret.git' does not appear to be a git repository"40+41+# What the client sees, ls-remote and archive.42+fails fetch_ssh cat dami/secret43+denied=$(output)44+aside dami/secret45+fails fetch_ssh cat dami/secret46+back dami/secret47+eq "$denied" "$(output)"48+has "$T_LOG" "'dami/secret.git' does not appear to be a git repository"49+ok fetch_ssh bob dami/secret50+# The server's stderr and the client's lines race, so compare them sorted.51+fails at "$T/w/bob" env FORGE_USER=cat git -c core.sshCommand="$T/ssh" archive --remote="$FORGE_SSH:dami/secret.git" main52+denied=$(output | sort)53+aside dami/secret54+fails at "$T/w/bob" env FORGE_USER=cat git -c core.sshCommand="$T/ssh" archive --remote="$FORGE_SSH:dami/secret.git" main55+back dami/secret56+eq "$denied" "$(output | sort)"57+ok at "$T/w/bob" env FORGE_USER=bob git -c core.sshCommand="$T/ssh" archive -o "$T/bob.tar" --remote="$FORGE_SSH:dami/secret.git" main58+59+# A public repo is still anyone's.60+repo dami/open61+ok fetch_ssh cat dami/open@@ -0,0 +1,62 @@
1+# ssh commands that name a private repo: readers use them as on any repo;2+# for anyone else each says what it says about a repo that isn't there.3+# ls marks private repos and lists the ones you may read.4+. "$(dirname "$0")/../lib.sh"5+user dami6+user ana7+user cat8+repo --private dami/secret9+repo dami/open10+clone dami dami/secret "$T/w/dami"11+at "$T/w/dami" commit "first"12+ok push_as dami "$T/w/dami" origin main13+at "$T/w/dami" git switch -q -c dami/topic14+at "$T/w/dami" commit "topic"15+ok push_as dami "$T/w/dami" origin dami/topic16+tree=$(git -C "$T/w/dami" rev-parse 'HEAD^{tree}')17+ok ssh_as dami readers dami/secret add ana18+19+# A reader: the review list comes from the review logs (no pages).20+ok ssh_as ana reviews dami/secret21+has "$T_LOG" '#1 +topic +dami/topic -> main'22+ok ssh_as ana reviews dami/secret --tsv23+has "$T_LOG" '^1 open dami/topic'24+printf 'looks fine\n' | ssh_as ana comment dami/secret 1 >"$T_LOG" 2>&1 || fail "reader can't comment"25+exists "$FORGE_ROOT/dami/secret.git/forge/comments/1/1"26+has "$FORGE_ROOT/.forge/events.jsonl" '"type":"comment".*"private":true}$'27+ok ssh_as ana approve dami/secret 128+ok ssh_as ana approvals dami/secret "$tree"29+has "$T_LOG" '^ana$'30+ok ssh_as ana site-url dami/secret docs31+32+# Anyone else: the same words as for a missing repo, byte for byte.33+for cmd in "reviews dami/secret" "reviews dami/secret --tsv" "comment dami/secret 1" "approve dami/secret 1" \34+ "approvals dami/secret $tree" "land dami/secret 1" "site-url dami/secret docs" \35+ "private dami/secret" "public dami/secret" "readers dami/secret" "readers dami/secret add cat"; do36+ fails ssh_as cat "$cmd" </dev/null37+ denied=$(output)38+ aside dami/secret39+ fails ssh_as cat "$cmd" </dev/null40+ back dami/secret41+ eq "$denied" "$(output)"42+ eq "$denied" "forge: no repo dami/secret"43+done44+missing "$FORGE_ROOT/dami/secret.git/forge/comments/1/2"45+46+ok ssh_as dami ls47+eq "$(output)" "open48+secret (private)"49+ok ssh_as ana ls50+eq "$(output)" "dami/secret (private)"51+ok ssh_as cat ls52+eq "$(output)" ""53+54+# new --private over ssh.55+ok ssh_as cat new --private notes "my notes"56+has "$T_LOG" 'created cat/notes \(private\)'57+missing "$FORGE_ROOT/cat/notes.git/git-daemon-export-ok"58+has "$FORGE_ROOT/cat/notes.git/description" '^my notes$'59+ok ssh_as cat new pub60+exists "$FORGE_ROOT/cat/pub.git/git-daemon-export-ok"61+ok ssh_as cat help62+has "$T_LOG" 'readers <owner>/<repo>'@@ -0,0 +1,78 @@
1+# private, then public again: going private takes the repo's pages, its2+# entries on the index, owner pages and repos.json, its sites, previews and3+# their hostnames off the forge, and the mirror's next sync sees it gone;4+# pushes meanwhile build and deploy nothing. Going public brings it all back.5+. "$(dirname "$0")/../lib.sh"6+user dami7+repo dami/greet "a greeter"8+repo dami/other "another"9+export FORGE_PAGES_DOMAIN=pages.test10+clone dami dami/greet "$T/w/dami"11+at "$T/w/dami" commit "first"12+ok push_as dami "$T/w/dami" origin main13+ok push_as dami "$T/w/dami" origin "$(site_commit "$T/w/dami" docs):refs/sites/docs"14+at "$T/w/dami" git switch -q -c dami/topic15+at "$T/w/dami" commit "topic"16+ok push_as dami "$T/w/dami" origin dami/topic "$(site_commit "$T/w/dami" preview):refs/site-previews/docs/dami/topic"17+build18+w=$FORGE_WWW19+exists "$w/dami/greet/index.html"20+has "$w/index.html" 'dami/greet'21+has "$w/dami/index.html" 'dami/greet'22+has "$w/repos.json" '"dami/greet"'23+exists "$w/sites/dami/greet/docs/index.html"24+exists "$w/previews/dami/greet/docs/dami~topic/index.html"25+eq "$(ls "$w/hosts" | sort | tr '\n' ' ')" "greet-docs-dami greet-docs-dami--dami-topic "26+27+# The mirror's sync (a stand-in rclone) runs after the flip, and sees what is left.28+mkdir "$T/bin"29+printf '#!/bin/sh\nls "$2/dami" >"%s/rclone.saw"\n' "$T" >"$T/bin/rclone"30+chmod +x "$T/bin/rclone"31+export PATH="$T/bin:$PATH" FORGE_MIRROR_S3=https://r2.test/pages FORGE_MIRROR_DELAY=032+sync_builds33+ok ssh_as dami private dami/greet34+has "$T_LOG" "can't be recalled"35+missing "$FORGE_ROOT/dami/greet.git/git-daemon-export-ok"36+missing "$w/dami/greet"37+lacks "$w/index.html" 'dami/greet'38+lacks "$w/dami/index.html" 'dami/greet'39+lacks "$w/repos.json" 'dami/greet'40+has "$w/repos.json" '"dami/other"'41+missing "$w/sites/dami/greet/docs"42+missing "$w/previews/dami/greet/docs/dami~topic"43+eq "$(ls "$w/hosts")" ""44+n=045+until [ -f "$T/rclone.saw" ] || [ "$n" -ge 50 ]; do sleep 0.2; n=$((n + 1)); done46+has "$T/rclone.saw" '^other$'47+lacks "$T/rclone.saw" 'greet'48+unset FORGE_MIRROR_S349+50+# Pushes to it now: no pages, no sites.51+at "$T/w/dami" commit "more"52+ok push_as dami "$T/w/dami" origin dami/topic "+$(site_commit "$T/w/dami" docs2):refs/sites/docs"53+has "$T_LOG" 'site docs: not served, since dami/greet is private'54+missing "$w/dami/greet"55+missing "$w/sites/dami/greet/docs"56+export FORGE_NO_BUILD=157+build58+missing "$w/dami/greet"59+lacks "$w/repos.json" 'dami/greet'60+ok ssh_as dami private dami/greet61+has "$T_LOG" 'already private'62+63+sync_builds64+ok ssh_as dami public dami/greet65+exists "$FORGE_ROOT/dami/greet.git/git-daemon-export-ok"66+exists "$w/dami/greet/index.html"67+has "$w/index.html" 'dami/greet'68+has "$w/repos.json" '"dami/greet"'69+has "$w/sites/dami/greet/docs/index.html" 'docs2'70+exists "$w/previews/dami/greet/docs/dami~topic/index.html"71+eq "$(ls "$w/hosts" | sort | tr '\n' ' ')" "greet-docs-dami greet-docs-dami--dami-topic "72+73+# Pages of a repo made private by hand go with the next build of it.74+export FORGE_NO_BUILD=175+rm "$FORGE_ROOT/dami/greet.git/git-daemon-export-ok"76+build dami/greet77+missing "$w/dami/greet"78+lacks "$w/repos.json" 'dami/greet'@@ -0,0 +1,28 @@
1+# Imports can land private: forge import --private (over ssh, into a new2+# repo or one that is already there) and forge repo import --private.3+. "$(dirname "$0")/../lib.sh"4+user dami5+mkdir "$T/w/src" && at "$T/w/src" git init -q6+at "$T/w/src" commit "upstream"7+src=$T/w/src8+9+imp() { FORGE_USER=dami GIT_SSH_COMMAND="$T/ssh" forge import --to "$FORGE_SSH" "$@"; }10+ok imp --private --desc "from elsewhere" "$src"11+missing "$FORGE_ROOT/dami/src.git/git-daemon-export-ok"12+eq "$(git --git-dir="$FORGE_ROOT/dami/src.git" rev-parse main)" "$(git -C "$src" rev-parse main)"13+has "$FORGE_ROOT/dami/src.git/description" '^from elsewhere$'14+15+# Into a public repo that exists: it goes private before the push lands.16+repo dami/other17+ok imp --private --name other "$src"18+missing "$FORGE_ROOT/dami/other.git/git-daemon-export-ok"19+eq "$(git --git-dir="$FORGE_ROOT/dami/other.git" rev-parse main)" "$(git -C "$src" rev-parse main)"20+21+ok imp --name plain "$src"22+exists "$FORGE_ROOT/dami/plain.git/git-daemon-export-ok"23+24+ok forge repo import --private dami/server "$src" "fetched"25+missing "$FORGE_ROOT/dami/server.git/git-daemon-export-ok"26+eq "$(git --git-dir="$FORGE_ROOT/dami/server.git" rev-parse main)" "$(git -C "$src" rev-parse main)"27+ok forge repo import dami/server2 "$src"28+exists "$FORGE_ROOT/dami/server2.git/git-daemon-export-ok"@@ -0,0 +1,24 @@
1+# The page build leaves private repos out entirely: no pages of their own,2+# no entry on the index, owner pages or repos.json, no search index.3+. "$(dirname "$0")/../lib.sh"4+user dami5+repo --private dami/secret "hidden thing"6+repo dami/open "open thing"7+for r in secret open; do8+ clone dami "dami/$r" "$T/w/$r"9+ at "$T/w/$r" commit "first"10+ ok push_as dami "$T/w/$r" origin main11+done12+build13+missing "$FORGE_WWW/dami/secret"14+exists "$FORGE_WWW/dami/open/index.html"15+for f in index.html dami/index.html repos.json; do16+ lacks "$FORGE_WWW/$f" 'dami/secret|hidden thing'17+ has "$FORGE_WWW/$f" 'dami/open'18+done19+if grep -rlq 'hidden thing' "$FORGE_WWW"; then fail "the private repo's description is in www"; fi20+build dami/secret21+has "$T_LOG" 'dami/secret is private: no pages to build'22+missing "$FORGE_WWW/dami/secret"23+# Its commit-graph is still kept up for git.24+exists "$FORGE_ROOT/dami/secret.git/objects/info/commit-graphs/commit-graph-chain"@@ -0,0 +1,42 @@
1+# Going public shows a repo's whole history at once, so public first runs2+# the secret check over all of it: a key added and removed long ago keeps3+# the repo private, unless --force. Pushes to a private repo are checked too.4+. "$(dirname "$0")/../lib.sh"5+user dami6+repo --private dami/secret7+clone dami dami/secret "$T/w/dami"8+key="AKIA$(printf 'QWERTYUIOPASDFGH')"9+printf 'aws_key = %s\n' "$key" >"$T/w/dami/config.ini"10+at "$T/w/dami" commit "add config" config.ini11+fails_with "this push adds what looks like a credential" push_as dami "$T/w/dami" origin main12+ok push_as dami "$T/w/dami" -o allow-secret origin main13+at "$T/w/dami" git rm -q config.ini14+at "$T/w/dami" commit "no config"15+ok push_as dami "$T/w/dami" origin main16+17+fails_with "dami/secret stays private: its history has what looks like a credential" ssh_as dami public dami/secret18+has "$T_LOG" "this repo's history has what looks like a credential"19+has "$T_LOG" 'config.ini:1 in .*AWS access key id'20+missing "$FORGE_ROOT/dami/secret.git/git-daemon-export-ok"21+ok ssh_as dami public dami/secret --force22+has "$T_LOG" 'making dami/secret public anyway'23+exists "$FORGE_ROOT/dami/secret.git/git-daemon-export-ok"24+25+# A clean history goes public without --force. One in an old revision of a26+# review (refs/forge/keep, which review pages show) is found too.27+repo --private dami/clean28+clone dami dami/clean "$T/w/clean"29+at "$T/w/clean" commit "first"30+ok push_as dami "$T/w/clean" origin main31+ok ssh_as dami public dami/clean32+exists "$FORGE_ROOT/dami/clean.git/git-daemon-export-ok"33+ok ssh_as dami private dami/clean34+at "$T/w/clean" git switch -q -c dami/oops35+printf 'token: %s\n' "$key" >"$T/w/clean/notes.txt"36+at "$T/w/clean" commit "oops" notes.txt37+ok push_as dami "$T/w/clean" -o allow-secret origin dami/oops38+at "$T/w/clean" git rm -q notes.txt39+at "$T/w/clean" git commit -q --amend --allow-empty -m "fixed"40+ok push_as dami "$T/w/clean" -f origin dami/oops41+fails_with "stays private" ssh_as dami public dami/clean42+has "$T_LOG" 'notes.txt:1'@@ -0,0 +1,44 @@
1+# Pushing to a private repo: someone who can't read it gets what a push to a2+# missing repo gets, and pre-receive turns them away too if anything lets3+# them that far. Readers push review branches like anyone on a public repo.4+. "$(dirname "$0")/../lib.sh"5+user dami6+user ana7+user cat8+repo --private dami/secret9+clone dami dami/secret "$T/w/dami"10+at "$T/w/dami" commit "first"11+ok push_as dami "$T/w/dami" origin main12+ok ssh_as dami readers dami/secret add ana13+14+mkdir "$T/w/cat" && at "$T/w/cat" git init -q15+at "$T/w/cat" commit "cat's"16+fails push_ssh cat "$T/w/cat" "$FORGE_SSH:dami/secret.git" HEAD:cat/x17+denied=$(output)18+aside dami/secret19+fails push_ssh cat "$T/w/cat" "$FORGE_SSH:dami/secret.git" HEAD:cat/x20+back dami/secret21+eq "$denied" "$(output)"22+has "$T_LOG" 'no repo dami/secret; only dami can create repos under dami/'23+for cmd in "git-receive-pack 'dami/secret.git'" "git receive-pack 'dami/../dami/secret'" "git-receive-pack '$FORGE_ROOT/dami/secret.git'" \24+ "git-receive-pack 'cat/../dami/secret.git'"; do25+ fails ssh_as cat "$cmd" </dev/null26+ denied=$(output)27+ aside dami/secret28+ fails ssh_as cat "$cmd" </dev/null29+ back dami/secret30+ eq "$denied" "$(output)"31+done32+missing "$FORGE_ROOT/cat/x.git"33+34+# Past forge shell (here the file transport), pre-receive says no.35+fails_with "no repo dami/secret" push_as cat "$T/w/cat" "$FORGE_ROOT/dami/secret.git" HEAD:refs/heads/cat/x36+fails git --git-dir="$FORGE_ROOT/dami/secret.git" rev-parse -q --verify refs/heads/cat/x37+38+# A reader pushes a review branch under their name, not main.39+ok clone_ssh ana dami/secret "$T/w/ana"40+at "$T/w/ana" commit "ana's idea"41+ok push_ssh ana "$T/w/ana" origin HEAD:ana/idea42+has "$FORGE_ROOT/dami/secret.git/forge/reviews/1" '^branch ana/idea$'43+has "$FORGE_ROOT/.forge/events.jsonl" '"type":"push","user":"ana".*"private":true}$'44+fails_with "only dami can push main" push_ssh ana "$T/w/ana" origin HEAD:main@@ -0,0 +1,44 @@
1+# readers add|rm: the owner and collaborators choose who reads a private2+# repo; names must be users of this forge; a reader who is removed can't3+# fetch any more.4+. "$(dirname "$0")/../lib.sh"5+user dami6+user ana7+user bob8+user cat9+repo --private dami/secret10+clone dami dami/secret "$T/w/dami"11+at "$T/w/dami" commit "first"12+ok push_as dami "$T/w/dami" origin main13+14+fails fetch_ssh bob dami/secret15+ok ssh_as dami readers dami/secret add bob16+has "$T_LOG" 'bob can read dami/secret'17+eq "$(cat "$FORGE_ROOT/dami/secret.git/forge/readers")" bob18+ok fetch_ssh bob dami/secret19+ok ssh_as dami readers dami/secret add bob20+has "$T_LOG" 'bob already reads'21+eq "$(grep -c . "$FORGE_ROOT/dami/secret.git/forge/readers")" 122+fails_with "no user nobody" ssh_as dami readers dami/secret add nobody23+fails_with "no user \\.\\./x" ssh_as dami readers dami/secret add ../x24+fails_with "usage: readers" ssh_as dami readers dami/secret drop bob25+26+# A reader reads but doesn't choose readers; a collaborator does.27+fails_with "only dami and collaborators of dami/secret" ssh_as bob readers dami/secret add cat28+fails_with "only dami and collaborators of dami/secret" ssh_as bob private dami/secret29+printf 'ana\n' >"$FORGE_ROOT/dami/secret.git/forge/collaborators"30+ok ssh_as ana readers dami/secret add cat31+ok ssh_as ana readers dami/secret32+eq "$(output)" "bob33+cat"34+35+ok ssh_as dami readers dami/secret rm bob36+eq "$(cat "$FORGE_ROOT/dami/secret.git/forge/readers")" cat37+fails fetch_ssh bob dami/secret38+fails_with "bob is not a reader" ssh_as dami readers dami/secret rm bob39+ok fetch_ssh cat dami/secret40+41+# On a public repo the list waits until it goes private.42+repo dami/open43+ok ssh_as dami readers dami/open add bob44+has "$T_LOG" 'counts once it is private'@@ -0,0 +1,30 @@
1+# A private repo still records reviews after a push, but serves no sites or2+# previews, unless git config forge.publicsites true; its events say private.3+. "$(dirname "$0")/../lib.sh"4+user dami5+repo --private dami/secret6+clone dami dami/secret "$T/w/dami"7+at "$T/w/dami" commit "first"8+ok push_as dami "$T/w/dami" origin main "$(site_commit "$T/w/dami" docs):refs/sites/docs"9+has "$T_LOG" 'site docs: not served, since dami/secret is private'10+git --git-dir="$FORGE_ROOT/dami/secret.git" rev-parse -q --verify refs/sites/docs >/dev/null || fail "the site ref is kept"11+missing "$FORGE_WWW/sites/dami/secret/docs"12+at "$T/w/dami" git switch -q -c dami/topic13+at "$T/w/dami" commit "topic"14+ok push_as dami "$T/w/dami" origin dami/topic "$(site_commit "$T/w/dami" preview):refs/site-previews/docs/dami/topic"15+exists "$FORGE_ROOT/dami/secret.git/forge/reviews/1"16+missing "$FORGE_WWW/previews/dami/secret/docs/dami~topic"17+has "$FORGE_ROOT/.forge/events.jsonl" '"type":"push".*"refs":" refs/heads/main refs/sites/docs","private":true}$'18+19+# forge.publicsites: the repo stays private, its sites are served.20+git --git-dir="$FORGE_ROOT/dami/secret.git" config forge.publicsites true21+ok push_as dami "$T/w/dami" origin "+$(site_commit "$T/w/dami" docs2):refs/sites/docs"22+has "$FORGE_WWW/sites/dami/secret/docs/index.html" 'docs2'23+# and going private again keeps them.24+repo dami/open25+git --git-dir="$FORGE_ROOT/dami/open.git" config forge.publicsites true26+clone dami dami/open "$T/w/open"27+at "$T/w/open" commit "first"28+ok push_as dami "$T/w/open" origin main "$(site_commit "$T/w/open" docs):refs/sites/docs"29+ok ssh_as dami private dami/open30+exists "$FORGE_WWW/sites/dami/open/docs/index.html"