unaltraweb 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +20 -0
- data/Makefile +412 -0
- data/README.md +226 -0
- data/_config.yml +842 -0
- data/_data/i18n/ca.yml +278 -0
- data/_data/i18n/en.yml +278 -0
- data/_data/i18n/es.yml +278 -0
- data/_includes/audio.liquid +30 -0
- data/_includes/bib_search.liquid +5 -0
- data/_includes/blog-list.liquid +66 -0
- data/_includes/book-citation.liquid +33 -0
- data/_includes/citation.liquid +26 -0
- data/_includes/custom_head.liquid +3 -0
- data/_includes/custom_scripts.liquid +5 -0
- data/_includes/cv/list.liquid +5 -0
- data/_includes/cv/list_groups.liquid +51 -0
- data/_includes/cv/map.liquid +29 -0
- data/_includes/cv/nested_list.liquid +16 -0
- data/_includes/cv/time_table.liquid +118 -0
- data/_includes/cv-download-card.liquid +26 -0
- data/_includes/developer-mode-switcher.liquid +24 -0
- data/_includes/disqus.liquid +13 -0
- data/_includes/distill_scripts.liquid +298 -0
- data/_includes/documentation-download-detail.liquid +76 -0
- data/_includes/documentation-downloads.liquid +71 -0
- data/_includes/documentation-profile-attributes.liquid +4 -0
- data/_includes/documentation-section-toc.liquid +9 -0
- data/_includes/documentation-sidebar.liquid +112 -0
- data/_includes/figure.liquid +87 -0
- data/_includes/footer.liquid +66 -0
- data/_includes/giscus.liquid +25 -0
- data/_includes/head.liquid +220 -0
- data/_includes/header.liquid +544 -0
- data/_includes/latest_posts.liquid +61 -0
- data/_includes/localized-date.liquid +5 -0
- data/_includes/manual-bibliography.liquid +2 -0
- data/_includes/manual-featured-readings.liquid +12 -0
- data/_includes/manual-other-readings.liquid +12 -0
- data/_includes/manual-page-toc.liquid +11 -0
- data/_includes/manual-sidebar.liquid +58 -0
- data/_includes/manual-teachers.liquid +74 -0
- data/_includes/metadata.liquid +278 -0
- data/_includes/news.liquid +48 -0
- data/_includes/newsletter.liquid +68 -0
- data/_includes/page-hero.liquid +96 -0
- data/_includes/page-link.liquid +8 -0
- data/_includes/pagination.liquid +22 -0
- data/_includes/post-reading-time.liquid +29 -0
- data/_includes/previous-next.liquid +37 -0
- data/_includes/profile-card.liquid +49 -0
- data/_includes/profile-feature-matrix.liquid +75 -0
- data/_includes/profile-highlights.liquid +122 -0
- data/_includes/project-grid.liquid +26 -0
- data/_includes/project-resource-link.liquid +45 -0
- data/_includes/project-resources.liquid +21 -0
- data/_includes/projects.liquid +38 -0
- data/_includes/projects_horizontal.liquid +37 -0
- data/_includes/publications-metrics-summary.liquid +69 -0
- data/_includes/publications-summary.liquid +127 -0
- data/_includes/reading-biblio-controls.liquid +65 -0
- data/_includes/reading-cover-card.liquid +93 -0
- data/_includes/reading-rating.liquid +11 -0
- data/_includes/recommended-readings.liquid +30 -0
- data/_includes/related_posts.liquid +39 -0
- data/_includes/repository/repo.liquid +48 -0
- data/_includes/repository/repo_trophies.liquid +44 -0
- data/_includes/repository/repo_user.liquid +35 -0
- data/_includes/resume/awards.liquid +19 -0
- data/_includes/resume/basics.liquid +28 -0
- data/_includes/resume/certificates.liquid +35 -0
- data/_includes/resume/education.liquid +54 -0
- data/_includes/resume/interests.liquid +34 -0
- data/_includes/resume/languages.liquid +31 -0
- data/_includes/resume/projects.liquid +32 -0
- data/_includes/resume/publications.liquid +28 -0
- data/_includes/resume/references.liquid +31 -0
- data/_includes/resume/skills.liquid +33 -0
- data/_includes/resume/volunteer.liquid +52 -0
- data/_includes/resume/work.liquid +52 -0
- data/_includes/scripts.liquid +364 -0
- data/_includes/search-data.js.liquid +341 -0
- data/_includes/selected_papers.liquid +3 -0
- data/_includes/t.liquid +31 -0
- data/_includes/team-grid.liquid +172 -0
- data/_includes/video.liquid +97 -0
- data/_layouts/about.liquid +93 -0
- data/_layouts/archive.liquid +45 -0
- data/_layouts/bib.liquid +677 -0
- data/_layouts/book-review.liquid +225 -0
- data/_layouts/book-shelf.liquid +66 -0
- data/_layouts/cv.liquid +127 -0
- data/_layouts/default.liquid +70 -0
- data/_layouts/distill.liquid +132 -0
- data/_layouts/documentation-download-detail.liquid +5 -0
- data/_layouts/documentation-home.liquid +100 -0
- data/_layouts/documentation-page.liquid +45 -0
- data/_layouts/manual-bib.liquid +73 -0
- data/_layouts/manual-chapter.liquid +99 -0
- data/_layouts/manual-featured-bib.liquid +100 -0
- data/_layouts/manual-home.liquid +120 -0
- data/_layouts/none.liquid +1 -0
- data/_layouts/outputs.liquid +29 -0
- data/_layouts/page.liquid +52 -0
- data/_layouts/post.liquid +95 -0
- data/_layouts/profile.liquid +36 -0
- data/_layouts/profiles.liquid +36 -0
- data/_layouts/project.liquid +7 -0
- data/_layouts/repositories.liquid +58 -0
- data/_layouts/theses.liquid +126 -0
- data/_plugins/bibliography_profiles.rb +164 -0
- data/_plugins/callouts.rb +104 -0
- data/_plugins/code_blocks.rb +277 -0
- data/_plugins/computation_figure_images.rb +266 -0
- data/_plugins/computation_sources.rb +42 -0
- data/_plugins/content_search_index.rb +210 -0
- data/_plugins/details.rb +23 -0
- data/_plugins/external-posts.rb +124 -0
- data/_plugins/figure_captions.rb +800 -0
- data/_plugins/file-exists.rb +22 -0
- data/_plugins/google-scholar-citations.rb +86 -0
- data/_plugins/hide-custom-bibtex.rb +18 -0
- data/_plugins/inspirehep-citations.rb +57 -0
- data/_plugins/localized_visual_sources.rb +145 -0
- data/_plugins/manual_release_metadata.rb +47 -0
- data/_plugins/mermaid_mmd_images.rb +233 -0
- data/_plugins/profile-pages.rb +28 -0
- data/_plugins/remove-accents.rb +32 -0
- data/_plugins/reproducible_build_time.rb +55 -0
- data/_plugins/search-data.rb +83 -0
- data/_plugins/theme-cache-bust.rb +56 -0
- data/_plugins/vega_visual_images.rb +290 -0
- data/_plugins/web_capture_images.rb +154 -0
- data/_sass/_base.scss +2133 -0
- data/_sass/_blog.scss +67 -0
- data/_sass/_callouts.scss +100 -0
- data/_sass/_custom.scss +437 -0
- data/_sass/_cv.scss +274 -0
- data/_sass/_developer.scss +97 -0
- data/_sass/_distill.scss +185 -0
- data/_sass/_documentation.scss +2220 -0
- data/_sass/_layout.scss +218 -0
- data/_sass/_manual.scss +2691 -0
- data/_sass/_profile.scss +323 -0
- data/_sass/_projects.scss +163 -0
- data/_sass/_reading.scss +465 -0
- data/_sass/_site-custom.scss +3 -0
- data/_sass/_tabs.scss +48 -0
- data/_sass/_team.scss +230 -0
- data/_sass/_themes.scss +254 -0
- data/_sass/_typograms.scss +132 -0
- data/_sass/_variables.scss +52 -0
- data/_sass/font-awesome/_animated.scss +152 -0
- data/_sass/font-awesome/_bordered-pulled.scss +20 -0
- data/_sass/font-awesome/_core.scss +49 -0
- data/_sass/font-awesome/_fixed-width.scss +7 -0
- data/_sass/font-awesome/_functions.scss +57 -0
- data/_sass/font-awesome/_icons.scss +13 -0
- data/_sass/font-awesome/_list.scss +18 -0
- data/_sass/font-awesome/_mixins.scss +65 -0
- data/_sass/font-awesome/_rotated-flipped.scss +31 -0
- data/_sass/font-awesome/_screen-reader.scss +14 -0
- data/_sass/font-awesome/_shims.scss +1578 -0
- data/_sass/font-awesome/_sizing.scss +16 -0
- data/_sass/font-awesome/_stacked.scss +32 -0
- data/_sass/font-awesome/_variables.scss +5044 -0
- data/_sass/font-awesome/brands.scss +30 -0
- data/_sass/font-awesome/fontawesome.scss +21 -0
- data/_sass/font-awesome/regular.scss +27 -0
- data/_sass/font-awesome/solid.scss +27 -0
- data/_sass/font-awesome/v4-shims.scss +11 -0
- data/_sass/tabler-icons/tabler-icons-filled.scss +1646 -0
- data/_sass/tabler-icons/tabler-icons-outline.scss +9859 -0
- data/_sass/tabler-icons/tabler-icons.scss +11463 -0
- data/_scripts/cronitor-analytics-setup.js +9 -0
- data/_scripts/giscus-setup.js +48 -0
- data/_scripts/google-analytics-setup.js +9 -0
- data/_scripts/open-panel-analytics-setup.js +14 -0
- data/_scripts/photoswipe-setup.js +11 -0
- data/assets/audio/epicaly-short-113909.mp3 +0 -0
- data/assets/bibliography/2018-12-22-distill.bib +7 -0
- data/assets/css/academicons.min.css +1 -0
- data/assets/css/bootstrap-toc.min.css +4 -0
- data/assets/css/bootstrap.min.css +7 -0
- data/assets/css/bootstrap.min.css.map +1 -0
- data/assets/css/jekyll-pygments-themes-github.css +209 -0
- data/assets/css/jekyll-pygments-themes-native.css +232 -0
- data/assets/css/jupyter-grade3.css +3758 -0
- data/assets/css/jupyter-monokai.css +3758 -0
- data/assets/css/jupyter.css +383 -0
- data/assets/css/main.scss +36 -0
- data/assets/css/mdb.min.css +48 -0
- data/assets/css/mdb.min.css.map +1 -0
- data/assets/css/scholar-icons.css +386 -0
- data/assets/fonts/academicons.ttf +0 -0
- data/assets/fonts/academicons.woff +0 -0
- data/assets/fonts/scholar-icons.ttf +0 -0
- data/assets/fonts/scholar-icons.woff +0 -0
- data/assets/fonts/tabler-icons-filled.ttf +0 -0
- data/assets/fonts/tabler-icons-filled.woff +0 -0
- data/assets/fonts/tabler-icons-filled.woff2 +0 -0
- data/assets/fonts/tabler-icons-outline.ttf +0 -0
- data/assets/fonts/tabler-icons-outline.woff +0 -0
- data/assets/fonts/tabler-icons-outline.woff2 +0 -0
- data/assets/fonts/tabler-icons.ttf +0 -0
- data/assets/fonts/tabler-icons.woff +0 -0
- data/assets/fonts/tabler-icons.woff2 +0 -0
- data/assets/html/relativity.html +15 -0
- data/assets/img/1.jpg +0 -0
- data/assets/img/10.jpg +0 -0
- data/assets/img/11.jpg +0 -0
- data/assets/img/12.jpg +0 -0
- data/assets/img/2.jpg +0 -0
- data/assets/img/3.jpg +0 -0
- data/assets/img/4.jpg +0 -0
- data/assets/img/5.jpg +0 -0
- data/assets/img/6.jpg +0 -0
- data/assets/img/7.jpg +0 -0
- data/assets/img/8.jpg +0 -0
- data/assets/img/9.jpg +0 -0
- data/assets/img/book_covers/the_godfather.jpg +0 -0
- data/assets/img/brand/dosquartsdedocs-logo-cafe.svg +31 -0
- data/assets/img/brand/dosquartsdedocs-logo-footer-cafe.svg +31 -0
- data/assets/img/brand/dosquartsdedocs-logo-white.svg +31 -0
- data/assets/img/brand/dosquartsdedocs-logo.svg +31 -0
- data/assets/img/prof_pic.jpg +0 -0
- data/assets/img/prof_pic_color.png +0 -0
- data/assets/img/profile-placeholder.svg +8 -0
- data/assets/img/publication_preview/brownian-motion.gif +0 -0
- data/assets/img/publication_preview/goodchild1992-gis-cover.jpg +0 -0
- data/assets/img/publication_preview/gutierrez2020-jtrg-preview.jpg +0 -0
- data/assets/img/publication_preview/harvey1989-postmodernity-cover.jpg +0 -0
- data/assets/img/publication_preview/tobler1970-economic-geography-cover.jpg +0 -0
- data/assets/img/publication_preview/tuan1977-space-place-cover.jpg +0 -0
- data/assets/img/publication_preview/wave-mechanics.gif +0 -0
- data/assets/img/publication_preview/zaragozi2025-rooftop-pv-preview.jpg +0 -0
- data/assets/img/rhino.png +0 -0
- data/assets/img/team/aaron-gutierrez.png +0 -0
- data/assets/img/team/alba-font.png +0 -0
- data/assets/img/team/alba-viana.png +0 -0
- data/assets/img/team/alejandro-gonzalez.png +0 -0
- data/assets/img/team/ana-pastor.png +0 -0
- data/assets/img/team/antoni-domenech.png +0 -0
- data/assets/img/team/benito-zaragozi.png +0 -0
- data/assets/img/team/cinta-sanz.png +0 -0
- data/assets/img/team/daniel-miravet.png +0 -0
- data/assets/img/team/eugenia-altamirano.png +0 -0
- data/assets/img/team/fiammetta-brandajs.png +0 -0
- data/assets/img/team/francesc-gonzalez.png +0 -0
- data/assets/img/team/isabel-paulino.png +0 -0
- data/assets/img/team/joan-borras.png +0 -0
- data/assets/img/team/julie-wilson.png +0 -0
- data/assets/img/team/liliana-sole.png +0 -0
- data/assets/img/team/maria-casado.png +0 -0
- data/assets/img/team/maria-medina.png +0 -0
- data/assets/img/team/marta-nello.png +0 -0
- data/assets/img/team/mogrify +1 -0
- data/assets/img/team/oscar-saladie.png +0 -0
- data/assets/img/team/paolo-russo.png +0 -0
- data/assets/img/team/raquel-santos.png +0 -0
- data/assets/img/team/salvador-anton.png +0 -0
- data/assets/img/team/sunsi-huertas.png +0 -0
- data/assets/img/team/user-placeholder.png +0 -0
- data/assets/img/team/wilbert-den-hoed.png +0 -0
- data/assets/img/team/xavier-delclos.png +0 -0
- data/assets/img/template_error.png +0 -0
- data/assets/js/bibsearch.js +70 -0
- data/assets/js/bootstrap-toc.min.js +5 -0
- data/assets/js/bootstrap.bundle.min.js +7 -0
- data/assets/js/bootstrap.bundle.min.js.map +1 -0
- data/assets/js/callouts.js +115 -0
- data/assets/js/chartjs-setup.js +14 -0
- data/assets/js/common.js +117 -0
- data/assets/js/content-search-match.js +159 -0
- data/assets/js/content-search.js +441 -0
- data/assets/js/copy_code.js +60 -0
- data/assets/js/custom.js +6 -0
- data/assets/js/developer-mode.js +103 -0
- data/assets/js/diff2html-setup.js +20 -0
- data/assets/js/distillpub/overrides.js +27 -0
- data/assets/js/distillpub/template.v2.js +9616 -0
- data/assets/js/distillpub/template.v2.js.map +1 -0
- data/assets/js/distillpub/transforms.v2.js +14557 -0
- data/assets/js/distillpub/transforms.v2.js.map +1 -0
- data/assets/js/documentation.js +435 -0
- data/assets/js/echarts-setup.js +29 -0
- data/assets/js/highlight-search-term.js +110 -0
- data/assets/js/jupyter_new_tab.js +18 -0
- data/assets/js/leaflet-setup.js +22 -0
- data/assets/js/manual.js +308 -0
- data/assets/js/masonry.js +12 -0
- data/assets/js/mathjax-setup.js +46 -0
- data/assets/js/mermaid-setup.js +37 -0
- data/assets/js/newsletter.js +105 -0
- data/assets/js/no_defer.js +24 -0
- data/assets/js/plotly-setup.js +52 -0
- data/assets/js/profile-nav.js +92 -0
- data/assets/js/progress-bar.js +73 -0
- data/assets/js/pseudocode-setup.js +18 -0
- data/assets/js/search/base-styles.min.js +286 -0
- data/assets/js/search/command-score.min.js +7 -0
- data/assets/js/search/hotkeys-js/hotkeys.esm.min.js +18 -0
- data/assets/js/search/lit/decorators.min.js +7 -0
- data/assets/js/search/lit/directives/class-map.min.js +7 -0
- data/assets/js/search/lit/directives/join.min.js +7 -0
- data/assets/js/search/lit/directives/live.min.js +7 -0
- data/assets/js/search/lit/directives/ref.min.js +7 -0
- data/assets/js/search/lit/directives/repeat.min.js +7 -0
- data/assets/js/search/lit/directives/unsafe-html.min.js +7 -0
- data/assets/js/search/lit/index.min.js +7 -0
- data/assets/js/search/lit-element/lit-element.min.js +12 -0
- data/assets/js/search/lit-html/directives/class-map.min.js +22 -0
- data/assets/js/search/lit-html/directives/join.min.js +12 -0
- data/assets/js/search/lit-html/directives/live.min.js +28 -0
- data/assets/js/search/lit-html/directives/ref.min.js +32 -0
- data/assets/js/search/lit-html/directives/repeat.min.js +27 -0
- data/assets/js/search/lit-html/directives/unsafe-html.min.js +22 -0
- data/assets/js/search/lit-html/is-server.min.js +12 -0
- data/assets/js/search/lit-html/lit-html.min.js +12 -0
- data/assets/js/search/ninja-action.min.js +101 -0
- data/assets/js/search/ninja-footer.min.js +62 -0
- data/assets/js/search/ninja-header.min.js +78 -0
- data/assets/js/search/ninja-keys.min.js +39 -0
- data/assets/js/search/reactive-element/decorators/custom-element.min.js +12 -0
- data/assets/js/search/reactive-element/decorators/event-options.min.js +12 -0
- data/assets/js/search/reactive-element/decorators/property.min.js +22 -0
- data/assets/js/search/reactive-element/decorators/query-all.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/query-assigned-elements.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/query-assigned-nodes.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/query-async.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/query.min.js +17 -0
- data/assets/js/search/reactive-element/decorators/state.min.js +27 -0
- data/assets/js/search/reactive-element/reactive-element.min.js +17 -0
- data/assets/js/search-setup.js +17 -0
- data/assets/js/shortcut-key.js +11 -0
- data/assets/js/tabs.min.js +5 -0
- data/assets/js/theme.js +314 -0
- data/assets/js/tooltips-setup.js +3 -0
- data/assets/js/typograms.js +1341 -0
- data/assets/js/vanilla-back-to-top.min.js +1 -0
- data/assets/js/vega-setup.js +24 -0
- data/assets/js/venobox-setup.js +5 -0
- data/assets/js/wechat.js +14 -0
- data/assets/js/zoom.js +15 -0
- data/assets/json/resume.json +98 -0
- data/assets/json/table_data.json +128 -0
- data/assets/jupyter/blog.ipynb +48 -0
- data/assets/pdf/example_pdf.pdf +0 -0
- data/assets/plotly/demo.html +72 -0
- data/assets/video/pexels-engin-akyurt-6069112-960x540-30fps.mp4 +0 -0
- data/assets/webfonts/fa-brands-400.ttf +0 -0
- data/assets/webfonts/fa-brands-400.woff2 +0 -0
- data/assets/webfonts/fa-regular-400.ttf +0 -0
- data/assets/webfonts/fa-regular-400.woff2 +0 -0
- data/assets/webfonts/fa-solid-900.ttf +0 -0
- data/assets/webfonts/fa-solid-900.woff2 +0 -0
- data/assets/webfonts/fa-v4compatibility.ttf +0 -0
- data/assets/webfonts/fa-v4compatibility.woff2 +0 -0
- data/docs/Gemfile +5 -0
- data/docs/_config.yml +83 -0
- data/docs/_documentation/en/01-quick-start.md +115 -0
- data/docs/_documentation/en/02-tools.md +146 -0
- data/docs/_documentation/en/03-usage.md +107 -0
- data/docs/_documentation/en/04-jekyll-basics.md +51 -0
- data/docs/_documentation/en/05-pages-collections.md +68 -0
- data/docs/_documentation/en/06-github-web-editing.md +92 -0
- data/docs/_documentation/en/10-profiles.md +125 -0
- data/docs/_documentation/en/11-unaltreselfie.md +39 -0
- data/docs/_documentation/en/12-unaltreprojecte.md +38 -0
- data/docs/_documentation/en/13-unaltremanual.md +237 -0
- data/docs/_documentation/en/14-unaltredocs.md +92 -0
- data/docs/_documentation/en/15-profile-checklist.md +39 -0
- data/docs/_documentation/en/20-syntax.md +353 -0
- data/docs/_documentation/en/21-themes.md +103 -0
- data/docs/_documentation/en/22-feature-reference.md +56 -0
- data/docs/_documentation/en/23-front-matter.md +51 -0
- data/docs/_documentation/en/24-data-files.md +28 -0
- data/docs/_documentation/en/30-customization.md +448 -0
- data/docs/_documentation/en/31-template.md +55 -0
- data/docs/_documentation/en/32-development.md +108 -0
- data/docs/_documentation/en/33-reusable-workflows.md +136 -0
- data/docs/_documentation/en/40-distribution.md +164 -0
- data/docs/_documentation/en/41-bibliometrics.md +137 -0
- data/docs/_documentation/en/42-docker-image.md +185 -0
- data/docs/_documentation/en/50-faq.md +33 -0
- data/docs/_pages/ca/index.md +35 -0
- data/docs/_pages/en/index.md +35 -0
- data/docs/_pages/es/index.md +35 -0
- data/docs/agents/action-prompts/00-start-site-session.txt +10 -0
- data/docs/agents/action-prompts/05-create-new-web.txt +5 -0
- data/docs/agents/action-prompts/10-content-update.txt +9 -0
- data/docs/agents/action-prompts/15-edit-default-content.txt +8 -0
- data/docs/agents/action-prompts/20-manual-teaching-materials.txt +18 -0
- data/docs/agents/action-prompts/22-manual-style-audit.txt +14 -0
- data/docs/agents/action-prompts/23-manual-structure-audit.txt +17 -0
- data/docs/agents/action-prompts/25-translation-prepublish.txt +8 -0
- data/docs/agents/action-prompts/30-project-site-update.txt +7 -0
- data/docs/agents/action-prompts/40-documentation-update.txt +7 -0
- data/docs/agents/action-prompts/50-bibliography-entry.txt +7 -0
- data/docs/agents/action-prompts/60-bibliometrics-refresh.txt +7 -0
- data/docs/agents/action-prompts/70-build-and-review.txt +12 -0
- data/docs/agents/manual-authoring-components.md +255 -0
- data/docs/agents/mcp-contract.md +162 -0
- data/docs/agents/quarto-computation-adoption.md +102 -0
- data/docs/assets/img/unaltraweb-docs-hero.svg +36 -0
- data/lib/unaltraweb/version.rb +8 -0
- data/lib/unaltraweb.rb +31 -0
- data/requirements.txt +5 -0
- data/scripts/biblio/fetch_scimago_csv.sh +126 -0
- data/scripts/biblio/metrics_common.py +154 -0
- data/scripts/biblio/metrics_merge_meta.py +73 -0
- data/scripts/biblio/metrics_update.py +826 -0
- data/scripts/computations/python/Dockerfile +37 -0
- data/scripts/computations/r/Dockerfile +31 -0
- data/scripts/computations/render.py +1267 -0
- data/scripts/cv/render_pdf_preview.sh +36 -0
- data/scripts/deploy/publish_branch.sh +80 -0
- data/scripts/generate_logo_variants.py +47 -0
- data/scripts/manual/Dockerfile +31 -0
- data/scripts/manual/build_pdf.py +1780 -0
- data/scripts/manual/filters/bibliography.lua +164 -0
- data/scripts/manual/filters/code-blocks.lua +102 -0
- data/scripts/manual/filters/figure-captions.lua +35 -0
- data/scripts/manual/publish_release.sh +281 -0
- data/scripts/manual/templates/manual.tex +494 -0
- data/scripts/manual/verify_release_assets.py +650 -0
- data/scripts/test_gem_build.py +128 -0
- data/scripts/test_reproducible_jekyll_build.py +191 -0
- data/scripts/test_wheel_install.py +282 -0
- data/scripts/unaltraweb-docker-mount.sh +36 -0
- data/scripts/unaltraweb-mcp-bootstrap.sh +105 -0
- data/scripts/unaltraweb-mcp-cleanup.sh +73 -0
- data/scripts/unaltraweb-mcp-project-id.sh +34 -0
- data/scripts/validate_distribution.py +563 -0
- data/scripts/validate_workflows.py +1285 -0
- data/scripts/web_captures/Dockerfile +31 -0
- data/scripts/web_captures/capture.mjs +157 -0
- data/scripts/web_captures/package-lock.json +56 -0
- data/scripts/web_captures/package.json +9 -0
- data/scripts/web_captures/render.py +867 -0
- data/src/unaltraweb_mcp/component-contract.json +153 -0
- data/src/unaltraweb_mcp/component-contract.schema.json +87 -0
- data/src/unaltraweb_mcp/docker_mount.py +19 -0
- metadata +861 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
import tarfile
|
|
11
|
+
import unicodedata
|
|
12
|
+
import zlib
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SELECTOR_RE = re.compile(r"v[0-9]{4}\.(?:0[1-9]|1[0-2])(?:\.[1-9][0-9]*)?\Z")
|
|
18
|
+
SHA256_RE = re.compile(r"[0-9a-f]{64}\Z")
|
|
19
|
+
IMAGE_RE = re.compile(r"ghcr\.io/dosquartsdedocs/unaltraweb-manual-pdf@sha256:[0-9a-f]{64}\Z")
|
|
20
|
+
SITE_IMAGE_RE = re.compile(r"ghcr\.io/dosquartsdedocs/unaltraweb-mcp@sha256:[0-9a-f]{64}\Z")
|
|
21
|
+
FINGERPRINT_SCHEMA = "unaltraweb-tree-v1"
|
|
22
|
+
MAX_JSON_BYTES = 8 * 1024 * 1024
|
|
23
|
+
MAX_TAR_MEMBERS = 200_000
|
|
24
|
+
MAX_SITE_ARCHIVE_BYTES = 2 * 1024 * 1024 * 1024
|
|
25
|
+
MAX_TAR_CONTENT_BYTES = 2 * 1024 * 1024 * 1024
|
|
26
|
+
MAX_TAR_STREAM_BYTES = 4 * 1024 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class VerificationError(RuntimeError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def canonical_json(value: Any) -> bytes:
|
|
34
|
+
return (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def strict_json(content: bytes, *, label: str) -> Any:
|
|
38
|
+
if len(content) > MAX_JSON_BYTES:
|
|
39
|
+
raise VerificationError(f"{label} exceeds the JSON size limit.")
|
|
40
|
+
|
|
41
|
+
def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
42
|
+
result: dict[str, Any] = {}
|
|
43
|
+
for key, value in pairs:
|
|
44
|
+
if key in result:
|
|
45
|
+
raise VerificationError(f"{label} contains duplicate key {key!r}.")
|
|
46
|
+
result[key] = value
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
def reject_constant(value: str) -> None:
|
|
50
|
+
raise VerificationError(f"{label} contains non-finite number {value}.")
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
value = json.loads(content.decode("utf-8"), object_pairs_hook=unique_object, parse_constant=reject_constant)
|
|
54
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
55
|
+
raise VerificationError(f"{label} is not strict UTF-8 JSON: {exc}") from exc
|
|
56
|
+
if canonical_json(value) != content:
|
|
57
|
+
raise VerificationError(f"{label} is not canonical JSON.")
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def name_key(value: str) -> str:
|
|
62
|
+
return unicodedata.normalize("NFC", value).casefold()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def safe_relative(value: Any, *, label: str) -> PurePosixPath:
|
|
66
|
+
if not isinstance(value, str) or not value or value != value.strip() or value.startswith("/") or "\\" in value:
|
|
67
|
+
raise VerificationError(f"{label} is not a safe relative path: {value!r}")
|
|
68
|
+
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
|
69
|
+
raise VerificationError(f"{label} contains control characters.")
|
|
70
|
+
parts = value.split("/")
|
|
71
|
+
if any(part in {"", ".", ".."} or part != part.strip() for part in parts):
|
|
72
|
+
raise VerificationError(f"{label} contains an empty or traversal segment: {value!r}")
|
|
73
|
+
return PurePosixPath(*parts)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def file_record(path: Path, *, label: str) -> dict[str, Any]:
|
|
77
|
+
descriptor: int | None = None
|
|
78
|
+
try:
|
|
79
|
+
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
|
|
80
|
+
before = os.fstat(descriptor)
|
|
81
|
+
if not stat.S_ISREG(before.st_mode):
|
|
82
|
+
raise VerificationError(f"{label} is not a regular file.")
|
|
83
|
+
digest = hashlib.sha256()
|
|
84
|
+
size = 0
|
|
85
|
+
while True:
|
|
86
|
+
chunk = os.read(descriptor, 1024 * 1024)
|
|
87
|
+
if not chunk:
|
|
88
|
+
break
|
|
89
|
+
digest.update(chunk)
|
|
90
|
+
size += len(chunk)
|
|
91
|
+
after = os.fstat(descriptor)
|
|
92
|
+
identity_before = (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns)
|
|
93
|
+
identity_after = (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns)
|
|
94
|
+
if identity_before != identity_after or size != after.st_size:
|
|
95
|
+
raise VerificationError(f"{label} changed while it was read.")
|
|
96
|
+
return {"sha256": digest.hexdigest(), "size": size}
|
|
97
|
+
except OSError as exc:
|
|
98
|
+
raise VerificationError(f"Cannot read {label}: {exc}") from exc
|
|
99
|
+
finally:
|
|
100
|
+
if descriptor is not None:
|
|
101
|
+
os.close(descriptor)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def regular_content(path: Path, *, label: str, max_bytes: int = MAX_JSON_BYTES) -> tuple[bytes, dict[str, Any]]:
|
|
105
|
+
descriptor: int | None = None
|
|
106
|
+
try:
|
|
107
|
+
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
|
|
108
|
+
before = os.fstat(descriptor)
|
|
109
|
+
if not stat.S_ISREG(before.st_mode) or before.st_size > max_bytes:
|
|
110
|
+
raise VerificationError(f"{label} is not a bounded regular file.")
|
|
111
|
+
content = bytearray()
|
|
112
|
+
digest = hashlib.sha256()
|
|
113
|
+
while len(content) <= max_bytes:
|
|
114
|
+
chunk = os.read(descriptor, min(65536, max_bytes + 1 - len(content)))
|
|
115
|
+
if not chunk:
|
|
116
|
+
break
|
|
117
|
+
content.extend(chunk)
|
|
118
|
+
digest.update(chunk)
|
|
119
|
+
after = os.fstat(descriptor)
|
|
120
|
+
identity_before = (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns)
|
|
121
|
+
identity_after = (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns)
|
|
122
|
+
if identity_before != identity_after or len(content) != after.st_size:
|
|
123
|
+
raise VerificationError(f"{label} changed while it was read.")
|
|
124
|
+
if len(content) > max_bytes:
|
|
125
|
+
raise VerificationError(f"{label} exceeds the size limit.")
|
|
126
|
+
return bytes(content), {"sha256": digest.hexdigest(), "size": len(content)}
|
|
127
|
+
except OSError as exc:
|
|
128
|
+
raise VerificationError(f"Cannot read {label}: {exc}") from exc
|
|
129
|
+
finally:
|
|
130
|
+
if descriptor is not None:
|
|
131
|
+
os.close(descriptor)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def regular_asset_inventory(assets: Path) -> dict[str, Path]:
|
|
135
|
+
try:
|
|
136
|
+
metadata = assets.lstat()
|
|
137
|
+
except OSError as exc:
|
|
138
|
+
raise VerificationError(f"Release asset directory is unavailable: {exc}") from exc
|
|
139
|
+
if not stat.S_ISDIR(metadata.st_mode):
|
|
140
|
+
raise VerificationError("Release asset root must be a real directory.")
|
|
141
|
+
inventory: dict[str, Path] = {}
|
|
142
|
+
keys: set[str] = set()
|
|
143
|
+
for entry in os.scandir(assets):
|
|
144
|
+
entry_metadata = entry.stat(follow_symlinks=False)
|
|
145
|
+
if not stat.S_ISREG(entry_metadata.st_mode):
|
|
146
|
+
raise VerificationError(f"Release asset must be a regular file: {entry.name}")
|
|
147
|
+
safe_relative(entry.name, label="release asset name")
|
|
148
|
+
key = name_key(entry.name)
|
|
149
|
+
if key in keys:
|
|
150
|
+
raise VerificationError(f"Release assets contain colliding names: {entry.name}")
|
|
151
|
+
keys.add(key)
|
|
152
|
+
inventory[entry.name] = Path(entry.path)
|
|
153
|
+
return inventory
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _compare_regular_files(reference: Path, received: Path, *, label: str) -> None:
|
|
157
|
+
descriptors: list[int] = []
|
|
158
|
+
try:
|
|
159
|
+
for path in (reference, received):
|
|
160
|
+
descriptors.append(
|
|
161
|
+
os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
|
|
162
|
+
)
|
|
163
|
+
reference_before, received_before = (os.fstat(descriptor) for descriptor in descriptors)
|
|
164
|
+
if not stat.S_ISREG(reference_before.st_mode) or not stat.S_ISREG(received_before.st_mode):
|
|
165
|
+
raise VerificationError(f"{label} must compare two regular files.")
|
|
166
|
+
if (reference_before.st_dev, reference_before.st_ino) == (received_before.st_dev, received_before.st_ino):
|
|
167
|
+
raise VerificationError(f"{label} must compare independently transferred files.")
|
|
168
|
+
|
|
169
|
+
identical = reference_before.st_size == received_before.st_size
|
|
170
|
+
while identical:
|
|
171
|
+
reference_chunk = os.read(descriptors[0], 1024 * 1024)
|
|
172
|
+
received_chunk = os.read(descriptors[1], 1024 * 1024)
|
|
173
|
+
if reference_chunk != received_chunk:
|
|
174
|
+
identical = False
|
|
175
|
+
break
|
|
176
|
+
if not reference_chunk:
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
reference_after, received_after = (os.fstat(descriptor) for descriptor in descriptors)
|
|
180
|
+
for name, before, after in [
|
|
181
|
+
("reference", reference_before, reference_after),
|
|
182
|
+
("received", received_before, received_after),
|
|
183
|
+
]:
|
|
184
|
+
before_identity = (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns)
|
|
185
|
+
after_identity = (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns)
|
|
186
|
+
if before_identity != after_identity:
|
|
187
|
+
raise VerificationError(f"{label} {name} changed while it was compared.")
|
|
188
|
+
if not identical:
|
|
189
|
+
raise VerificationError(f"{label} differs from the locally verified release asset.")
|
|
190
|
+
except OSError as exc:
|
|
191
|
+
raise VerificationError(f"Cannot compare {label}: {exc}") from exc
|
|
192
|
+
finally:
|
|
193
|
+
for descriptor in descriptors:
|
|
194
|
+
os.close(descriptor)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def compare_asset_directories(reference: Path, received: Path) -> None:
|
|
198
|
+
try:
|
|
199
|
+
reference_metadata = reference.lstat()
|
|
200
|
+
received_metadata = received.lstat()
|
|
201
|
+
except OSError as exc:
|
|
202
|
+
raise VerificationError(f"Cannot inspect release asset roots for exact comparison: {exc}") from exc
|
|
203
|
+
if (reference_metadata.st_dev, reference_metadata.st_ino) == (received_metadata.st_dev, received_metadata.st_ino):
|
|
204
|
+
raise VerificationError("Reference and received release asset roots must be independent directories.")
|
|
205
|
+
|
|
206
|
+
reference_assets = regular_asset_inventory(reference)
|
|
207
|
+
received_assets = regular_asset_inventory(received)
|
|
208
|
+
if set(reference_assets) != set(received_assets):
|
|
209
|
+
raise VerificationError("Received release assets do not exactly match the local asset names.")
|
|
210
|
+
for name in sorted(reference_assets):
|
|
211
|
+
_compare_regular_files(
|
|
212
|
+
reference_assets[name],
|
|
213
|
+
received_assets[name],
|
|
214
|
+
label=f"received release asset {name}",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _validate_single_gzip_member(
|
|
219
|
+
descriptor: int,
|
|
220
|
+
*,
|
|
221
|
+
logical_tar_end: int | None = None,
|
|
222
|
+
expected_metadata: os.stat_result | None = None,
|
|
223
|
+
) -> os.stat_result:
|
|
224
|
+
before = os.fstat(descriptor)
|
|
225
|
+
if not stat.S_ISREG(before.st_mode):
|
|
226
|
+
raise VerificationError("Site archive is not a regular file.")
|
|
227
|
+
if before.st_size > MAX_SITE_ARCHIVE_BYTES:
|
|
228
|
+
raise VerificationError("Site archive exceeds the compressed size limit.")
|
|
229
|
+
before_identity = (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns)
|
|
230
|
+
if expected_metadata is not None:
|
|
231
|
+
expected_identity = (
|
|
232
|
+
expected_metadata.st_dev,
|
|
233
|
+
expected_metadata.st_ino,
|
|
234
|
+
expected_metadata.st_size,
|
|
235
|
+
expected_metadata.st_mtime_ns,
|
|
236
|
+
expected_metadata.st_ctime_ns,
|
|
237
|
+
)
|
|
238
|
+
if before_identity != expected_identity:
|
|
239
|
+
raise VerificationError("Site archive changed before its tar padding was validated.")
|
|
240
|
+
if logical_tar_end is not None and (logical_tar_end < 0 or logical_tar_end % tarfile.BLOCKSIZE):
|
|
241
|
+
raise VerificationError("Site archive logical tar end is not block aligned.")
|
|
242
|
+
|
|
243
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
244
|
+
decoder = zlib.decompressobj(16 + zlib.MAX_WBITS)
|
|
245
|
+
decoded_size = 0
|
|
246
|
+
try:
|
|
247
|
+
while True:
|
|
248
|
+
chunk = os.read(descriptor, 1024 * 1024)
|
|
249
|
+
if not chunk:
|
|
250
|
+
break
|
|
251
|
+
if decoder.eof:
|
|
252
|
+
raise VerificationError("Site archive has trailing bytes or a concatenated gzip member.")
|
|
253
|
+
pending = chunk
|
|
254
|
+
while pending:
|
|
255
|
+
output = decoder.decompress(
|
|
256
|
+
pending,
|
|
257
|
+
min(1024 * 1024, MAX_TAR_STREAM_BYTES - decoded_size + 1),
|
|
258
|
+
)
|
|
259
|
+
output_start = decoded_size
|
|
260
|
+
decoded_size += len(output)
|
|
261
|
+
if decoded_size > MAX_TAR_STREAM_BYTES:
|
|
262
|
+
raise VerificationError("Site archive exceeds the decompressed stream size limit.")
|
|
263
|
+
if logical_tar_end is not None and decoded_size > logical_tar_end:
|
|
264
|
+
tail_start = max(0, logical_tar_end - output_start)
|
|
265
|
+
if output[tail_start:].strip(b"\0"):
|
|
266
|
+
raise VerificationError("Site archive contains nonzero data after its logical tar end.")
|
|
267
|
+
if decoder.eof:
|
|
268
|
+
if decoder.unused_data:
|
|
269
|
+
raise VerificationError("Site archive has trailing bytes or a concatenated gzip member.")
|
|
270
|
+
break
|
|
271
|
+
pending = decoder.unconsumed_tail
|
|
272
|
+
if not decoder.eof:
|
|
273
|
+
raise VerificationError("Site archive contains an incomplete gzip stream.")
|
|
274
|
+
except zlib.error as exc:
|
|
275
|
+
raise VerificationError(f"Site archive is not a valid gzip stream: {exc}") from exc
|
|
276
|
+
|
|
277
|
+
if logical_tar_end is not None:
|
|
278
|
+
if decoded_size % tarfile.BLOCKSIZE:
|
|
279
|
+
raise VerificationError("Site archive decompressed stream is not block aligned.")
|
|
280
|
+
if decoded_size - logical_tar_end < 2 * tarfile.BLOCKSIZE:
|
|
281
|
+
raise VerificationError("Site archive omits the canonical two-block tar end marker.")
|
|
282
|
+
|
|
283
|
+
after = os.fstat(descriptor)
|
|
284
|
+
after_identity = (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns)
|
|
285
|
+
if before_identity != after_identity:
|
|
286
|
+
raise VerificationError("Site archive changed while its gzip stream was validated.")
|
|
287
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
288
|
+
return before
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def tar_tree_record(archive_path: Path) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
|
|
292
|
+
directories: dict[str, dict[str, Any]] = {}
|
|
293
|
+
files: dict[str, dict[str, Any]] = {}
|
|
294
|
+
keys: set[str] = set()
|
|
295
|
+
root_members = 0
|
|
296
|
+
member_count = 0
|
|
297
|
+
content_size = 0
|
|
298
|
+
logical_tar_end: int | None = None
|
|
299
|
+
descriptor: int | None = None
|
|
300
|
+
try:
|
|
301
|
+
descriptor = os.open(
|
|
302
|
+
archive_path,
|
|
303
|
+
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0),
|
|
304
|
+
)
|
|
305
|
+
archive_metadata = _validate_single_gzip_member(descriptor)
|
|
306
|
+
with os.fdopen(os.dup(descriptor), "rb") as archive_file:
|
|
307
|
+
with tarfile.open(fileobj=archive_file, mode="r:gz") as archive:
|
|
308
|
+
for member in archive:
|
|
309
|
+
member_count += 1
|
|
310
|
+
if member_count > MAX_TAR_MEMBERS:
|
|
311
|
+
raise VerificationError("Site archive contains too many members.")
|
|
312
|
+
if member.uid != 0 or member.gid != 0 or member.mtime != 0:
|
|
313
|
+
raise VerificationError("Site archive contains non-canonical owner or timestamp metadata.")
|
|
314
|
+
raw = member.name
|
|
315
|
+
while raw.startswith("./"):
|
|
316
|
+
raw = raw[2:]
|
|
317
|
+
raw = raw.rstrip("/")
|
|
318
|
+
if raw in {"", "."}:
|
|
319
|
+
root_members += 1
|
|
320
|
+
if not member.isdir() or member.mode & 0o7777 != 0o755:
|
|
321
|
+
raise VerificationError("Site archive root member is not one canonical mode-0755 directory.")
|
|
322
|
+
continue
|
|
323
|
+
relative = safe_relative(raw, label="site archive member").as_posix()
|
|
324
|
+
key = name_key(relative)
|
|
325
|
+
if key in keys:
|
|
326
|
+
raise VerificationError(f"Site archive contains duplicate or colliding member: {relative}")
|
|
327
|
+
keys.add(key)
|
|
328
|
+
if member.isdir():
|
|
329
|
+
if member.mode & 0o7777 != 0o755:
|
|
330
|
+
raise VerificationError(f"Site archive directory does not use canonical mode 0755: {relative}")
|
|
331
|
+
directories[relative] = {"path": relative, "mode": member.mode & 0o7777}
|
|
332
|
+
continue
|
|
333
|
+
if not member.isfile():
|
|
334
|
+
raise VerificationError(f"Site archive contains a link or special member: {relative}")
|
|
335
|
+
if member.mode & 0o7777 != 0o644:
|
|
336
|
+
raise VerificationError(f"Site archive file does not use canonical mode 0644: {relative}")
|
|
337
|
+
if member.size > MAX_TAR_CONTENT_BYTES - content_size:
|
|
338
|
+
raise VerificationError("Site archive exceeds the file content size limit.")
|
|
339
|
+
content_size += member.size
|
|
340
|
+
source = archive.extractfile(member)
|
|
341
|
+
if source is None:
|
|
342
|
+
raise VerificationError(f"Cannot read site archive member: {relative}")
|
|
343
|
+
digest = hashlib.sha256()
|
|
344
|
+
size = 0
|
|
345
|
+
while True:
|
|
346
|
+
chunk = source.read(1024 * 1024)
|
|
347
|
+
if not chunk:
|
|
348
|
+
break
|
|
349
|
+
digest.update(chunk)
|
|
350
|
+
size += len(chunk)
|
|
351
|
+
if size != member.size:
|
|
352
|
+
raise VerificationError(f"Site archive member has an inconsistent size: {relative}")
|
|
353
|
+
files[relative] = {
|
|
354
|
+
"path": relative,
|
|
355
|
+
"sha256": digest.hexdigest(),
|
|
356
|
+
"size": size,
|
|
357
|
+
"mode": member.mode & 0o7777,
|
|
358
|
+
}
|
|
359
|
+
logical_tar_end = archive.offset
|
|
360
|
+
if logical_tar_end is None:
|
|
361
|
+
raise VerificationError("Site archive has no logical tar end.")
|
|
362
|
+
_validate_single_gzip_member(
|
|
363
|
+
descriptor,
|
|
364
|
+
logical_tar_end=logical_tar_end,
|
|
365
|
+
expected_metadata=archive_metadata,
|
|
366
|
+
)
|
|
367
|
+
final_metadata = os.fstat(descriptor)
|
|
368
|
+
archive_identity = (
|
|
369
|
+
archive_metadata.st_dev,
|
|
370
|
+
archive_metadata.st_ino,
|
|
371
|
+
archive_metadata.st_size,
|
|
372
|
+
archive_metadata.st_mtime_ns,
|
|
373
|
+
archive_metadata.st_ctime_ns,
|
|
374
|
+
)
|
|
375
|
+
final_identity = (
|
|
376
|
+
final_metadata.st_dev,
|
|
377
|
+
final_metadata.st_ino,
|
|
378
|
+
final_metadata.st_size,
|
|
379
|
+
final_metadata.st_mtime_ns,
|
|
380
|
+
final_metadata.st_ctime_ns,
|
|
381
|
+
)
|
|
382
|
+
if archive_identity != final_identity:
|
|
383
|
+
raise VerificationError("Site archive changed while it was inspected.")
|
|
384
|
+
finally:
|
|
385
|
+
if descriptor is not None:
|
|
386
|
+
os.close(descriptor)
|
|
387
|
+
|
|
388
|
+
if root_members != 1:
|
|
389
|
+
raise VerificationError("Site archive must contain exactly one canonical root directory member.")
|
|
390
|
+
|
|
391
|
+
children: dict[str, set[str]] = {"": set()}
|
|
392
|
+
for relative in [*directories, *files]:
|
|
393
|
+
path = PurePosixPath(relative)
|
|
394
|
+
parent = "" if path.parent == PurePosixPath(".") else path.parent.as_posix()
|
|
395
|
+
if parent and parent not in directories:
|
|
396
|
+
raise VerificationError(f"Site archive omits parent directory for {relative}.")
|
|
397
|
+
children.setdefault(parent, set()).add(path.name)
|
|
398
|
+
children.setdefault(relative, set())
|
|
399
|
+
|
|
400
|
+
ordered_directories: list[dict[str, Any]] = []
|
|
401
|
+
ordered_files: list[dict[str, Any]] = []
|
|
402
|
+
|
|
403
|
+
def walk(parent: str) -> None:
|
|
404
|
+
for child in sorted(children.get(parent, set())):
|
|
405
|
+
relative = f"{parent}/{child}" if parent else child
|
|
406
|
+
if relative in directories:
|
|
407
|
+
ordered_directories.append(directories[relative])
|
|
408
|
+
walk(relative)
|
|
409
|
+
elif relative in files:
|
|
410
|
+
ordered_files.append(files[relative])
|
|
411
|
+
else:
|
|
412
|
+
raise VerificationError(f"Site archive inventory is inconsistent at {relative}.")
|
|
413
|
+
|
|
414
|
+
walk("")
|
|
415
|
+
if len(ordered_directories) != len(directories) or len(ordered_files) != len(files):
|
|
416
|
+
raise VerificationError("Site archive contains unreachable inventory entries.")
|
|
417
|
+
payload = {
|
|
418
|
+
"schema": FINGERPRINT_SCHEMA,
|
|
419
|
+
"directories": ordered_directories,
|
|
420
|
+
"files": ordered_files,
|
|
421
|
+
}
|
|
422
|
+
tree = {
|
|
423
|
+
"bytes": sum(record["size"] for record in ordered_files),
|
|
424
|
+
"file_count": len(ordered_files),
|
|
425
|
+
"path": "site",
|
|
426
|
+
"sha256": hashlib.sha256(b"unaltraweb-tree-v1\0" + canonical_json(payload)).hexdigest(),
|
|
427
|
+
}
|
|
428
|
+
return tree, files
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def verify_sums(path: Path, assets: dict[str, Path], expected_names: set[str]) -> None:
|
|
432
|
+
content, _ = regular_content(path, label="SHA256SUMS")
|
|
433
|
+
lines = content.splitlines(keepends=True)
|
|
434
|
+
if not lines or any(not line.endswith(b"\n") for line in lines):
|
|
435
|
+
raise VerificationError("SHA256SUMS must be non-empty and newline terminated.")
|
|
436
|
+
received: dict[str, str] = {}
|
|
437
|
+
for line in lines:
|
|
438
|
+
value = line[:-1].decode("utf-8")
|
|
439
|
+
if " " not in value:
|
|
440
|
+
raise VerificationError("SHA256SUMS contains a malformed line.")
|
|
441
|
+
digest, raw_name = value.split(" ", 1)
|
|
442
|
+
if raw_name.startswith("./"):
|
|
443
|
+
raw_name = raw_name[2:]
|
|
444
|
+
name = safe_relative(raw_name, label="SHA256SUMS asset").as_posix()
|
|
445
|
+
if "/" in name or not SHA256_RE.fullmatch(digest) or name in received:
|
|
446
|
+
raise VerificationError("SHA256SUMS contains an unsafe, duplicate, or invalid entry.")
|
|
447
|
+
received[name] = digest
|
|
448
|
+
if set(received) != expected_names:
|
|
449
|
+
raise VerificationError("SHA256SUMS does not exactly inventory the authorized release assets.")
|
|
450
|
+
for name, digest in received.items():
|
|
451
|
+
if file_record(assets[name], label=f"release asset {name}")["sha256"] != digest:
|
|
452
|
+
raise VerificationError(f"Release asset checksum mismatch: {name}")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def verify(args: argparse.Namespace) -> dict[str, Any]:
|
|
456
|
+
if not SELECTOR_RE.fullmatch(args.selector):
|
|
457
|
+
raise VerificationError("Invalid stable selector.")
|
|
458
|
+
for label, value in [
|
|
459
|
+
("reviewed SHA", args.reviewed_sha),
|
|
460
|
+
("core SHA", args.core_sha),
|
|
461
|
+
]:
|
|
462
|
+
if not re.fullmatch(r"[0-9a-f]{40}", value):
|
|
463
|
+
raise VerificationError(f"Invalid {label}.")
|
|
464
|
+
if not SHA256_RE.fullmatch(args.candidate_manifest_sha256):
|
|
465
|
+
raise VerificationError("Invalid candidate manifest SHA-256.")
|
|
466
|
+
if not IMAGE_RE.fullmatch(args.manual_pdf_image):
|
|
467
|
+
raise VerificationError("Invalid manual PDF image digest reference.")
|
|
468
|
+
if not SITE_IMAGE_RE.fullmatch(args.site_build_image):
|
|
469
|
+
raise VerificationError("Invalid stable site-build image digest reference.")
|
|
470
|
+
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", args.python_version) or not re.fullmatch(
|
|
471
|
+
r"[0-9]+\.[0-9]+\.[0-9]+", args.ruby_version
|
|
472
|
+
):
|
|
473
|
+
raise VerificationError("Stable runtime versions must use exact three-part versions.")
|
|
474
|
+
if args.vegavisuals_sha and not re.fullmatch(r"[0-9a-f]{40}", args.vegavisuals_sha):
|
|
475
|
+
raise VerificationError("Invalid vegavisuals SHA.")
|
|
476
|
+
|
|
477
|
+
assets_root = args.assets.absolute()
|
|
478
|
+
assets = regular_asset_inventory(assets_root)
|
|
479
|
+
manifest_path = assets.get("release-manifest.json")
|
|
480
|
+
publication_path = assets.get("publication.json")
|
|
481
|
+
if manifest_path is None or publication_path is None:
|
|
482
|
+
raise VerificationError("Release assets omit required publication metadata.")
|
|
483
|
+
|
|
484
|
+
manifest_content, manifest_record = regular_content(manifest_path, label="release-manifest.json")
|
|
485
|
+
if manifest_record["sha256"] != args.candidate_manifest_sha256:
|
|
486
|
+
raise VerificationError("release-manifest.json does not match the locally reviewed digest.")
|
|
487
|
+
manifest = strict_json(manifest_content, label="release-manifest.json")
|
|
488
|
+
expected_manifest_keys = {"channel", "fingerprint_schema", "pdfs", "schema_version", "selector", "site", "source"}
|
|
489
|
+
if not isinstance(manifest, dict) or set(manifest) != expected_manifest_keys:
|
|
490
|
+
raise VerificationError("release-manifest.json has an unexpected schema.")
|
|
491
|
+
if (
|
|
492
|
+
manifest["channel"] != "stable"
|
|
493
|
+
or manifest["fingerprint_schema"] != FINGERPRINT_SCHEMA
|
|
494
|
+
or manifest["schema_version"] != 2
|
|
495
|
+
or manifest["selector"] != args.selector
|
|
496
|
+
):
|
|
497
|
+
raise VerificationError("release-manifest.json does not identify the authorized stable release.")
|
|
498
|
+
source = manifest.get("source")
|
|
499
|
+
if (
|
|
500
|
+
not isinstance(source, dict)
|
|
501
|
+
or set(source) != {"bytes", "commit", "file_count", "sha256", "site_build_image", "source_date_epoch"}
|
|
502
|
+
or type(source.get("bytes")) is not int
|
|
503
|
+
or type(source.get("file_count")) is not int
|
|
504
|
+
or source.get("commit") != args.reviewed_sha
|
|
505
|
+
or source.get("site_build_image") != args.site_build_image
|
|
506
|
+
or not SHA256_RE.fullmatch(str(source.get("sha256") or ""))
|
|
507
|
+
or not re.fullmatch(r"[0-9]+", str(source.get("source_date_epoch") or ""))
|
|
508
|
+
):
|
|
509
|
+
raise VerificationError("release-manifest.json has an invalid source fingerprint.")
|
|
510
|
+
|
|
511
|
+
publication_content, _ = regular_content(publication_path, label="publication.json")
|
|
512
|
+
publication = strict_json(publication_content, label="publication.json")
|
|
513
|
+
if not isinstance(publication, dict) or not re.fullmatch(
|
|
514
|
+
r"[0-9]+\.[0-9]+\.[0-9]+", str(publication.get("site_build_python_version") or "")
|
|
515
|
+
):
|
|
516
|
+
raise VerificationError("publication.json does not record a valid site-build Python version.")
|
|
517
|
+
expected_publication = {
|
|
518
|
+
"candidate_manifest_sha256": args.candidate_manifest_sha256,
|
|
519
|
+
"channel": "stable",
|
|
520
|
+
"core_sha": args.core_sha,
|
|
521
|
+
"manual_pdf_image": args.manual_pdf_image,
|
|
522
|
+
"python_version": args.python_version,
|
|
523
|
+
"repository": args.repository,
|
|
524
|
+
"ruby_version": args.ruby_version,
|
|
525
|
+
"schema_version": 2,
|
|
526
|
+
"selector": args.selector,
|
|
527
|
+
"site_build_image": args.site_build_image,
|
|
528
|
+
"site_build_python_version": publication["site_build_python_version"],
|
|
529
|
+
"source_date_epoch": source["source_date_epoch"],
|
|
530
|
+
"source_sha": args.reviewed_sha,
|
|
531
|
+
"vegavisuals_sha": args.vegavisuals_sha,
|
|
532
|
+
}
|
|
533
|
+
if publication != expected_publication:
|
|
534
|
+
raise VerificationError("publication.json does not match the authorized release request.")
|
|
535
|
+
|
|
536
|
+
pdfs = manifest["pdfs"]
|
|
537
|
+
if not isinstance(pdfs, list) or not pdfs:
|
|
538
|
+
raise VerificationError("release-manifest.json has no PDF inventory.")
|
|
539
|
+
expected_asset_names = {
|
|
540
|
+
"SHA256SUMS",
|
|
541
|
+
"publication.json",
|
|
542
|
+
"release-manifest.json",
|
|
543
|
+
f"manual-site-{args.selector}.tar.gz",
|
|
544
|
+
}
|
|
545
|
+
pdf_asset_records: dict[str, dict[str, Any]] = {}
|
|
546
|
+
expected_pdf_keys = {
|
|
547
|
+
"cover_path",
|
|
548
|
+
"cover_sha256",
|
|
549
|
+
"cover_site_path",
|
|
550
|
+
"cover_size",
|
|
551
|
+
"draft",
|
|
552
|
+
"language",
|
|
553
|
+
"path",
|
|
554
|
+
"published_path",
|
|
555
|
+
"sha256",
|
|
556
|
+
"site_path",
|
|
557
|
+
"size",
|
|
558
|
+
}
|
|
559
|
+
for record in pdfs:
|
|
560
|
+
if not isinstance(record, dict) or set(record) != expected_pdf_keys or record.get("draft") is not False:
|
|
561
|
+
raise VerificationError("Stable PDF inventory contains an invalid or draft record.")
|
|
562
|
+
pdf_path = safe_relative(record.get("path"), label="standalone PDF path")
|
|
563
|
+
published_path = safe_relative(record.get("published_path"), label="published PDF path")
|
|
564
|
+
site_path = safe_relative(record.get("site_path"), label="site PDF path")
|
|
565
|
+
cover_path = safe_relative(record.get("cover_path"), label="published cover path")
|
|
566
|
+
cover_site_path = safe_relative(record.get("cover_site_path"), label="site cover path")
|
|
567
|
+
if len(pdf_path.parts) != 2 or pdf_path.parts[0] != "pdf" or pdf_path.suffix.lower() != ".pdf":
|
|
568
|
+
raise VerificationError("Standalone PDF path is misplaced or has the wrong suffix.")
|
|
569
|
+
if site_path.parts != ("site", *published_path.parts) or cover_site_path.parts != ("site", *cover_path.parts):
|
|
570
|
+
raise VerificationError("PDF or cover site path does not match its published path.")
|
|
571
|
+
if pdf_path.name != published_path.name:
|
|
572
|
+
raise VerificationError("Standalone and published PDF names differ.")
|
|
573
|
+
if not SHA256_RE.fullmatch(str(record.get("sha256") or "")) or not SHA256_RE.fullmatch(str(record.get("cover_sha256") or "")):
|
|
574
|
+
raise VerificationError("PDF inventory contains an invalid digest.")
|
|
575
|
+
if type(record.get("size")) is not int or type(record.get("cover_size")) is not int:
|
|
576
|
+
raise VerificationError("PDF inventory contains an invalid size.")
|
|
577
|
+
key = name_key(pdf_path.name)
|
|
578
|
+
if key in {name_key(name) for name in expected_asset_names} or key in pdf_asset_records:
|
|
579
|
+
raise VerificationError("PDF asset names collide.")
|
|
580
|
+
expected_asset_names.add(pdf_path.name)
|
|
581
|
+
pdf_asset_records[key] = record
|
|
582
|
+
|
|
583
|
+
if set(assets) != expected_asset_names:
|
|
584
|
+
raise VerificationError("Release artifact contains missing or unauthorized files.")
|
|
585
|
+
|
|
586
|
+
site_archive = assets[f"manual-site-{args.selector}.tar.gz"]
|
|
587
|
+
site_tree, site_files = tar_tree_record(site_archive)
|
|
588
|
+
if manifest.get("site") != site_tree:
|
|
589
|
+
raise VerificationError("Site archive fingerprint does not match release-manifest.json.")
|
|
590
|
+
for record in pdfs:
|
|
591
|
+
pdf_name = PurePosixPath(record["path"]).name
|
|
592
|
+
standalone = file_record(assets[pdf_name], label=f"standalone PDF {pdf_name}")
|
|
593
|
+
if standalone != {"sha256": record["sha256"], "size": record["size"]}:
|
|
594
|
+
raise VerificationError(f"Standalone PDF does not match release-manifest.json: {pdf_name}")
|
|
595
|
+
site_pdf = PurePosixPath(record["site_path"]).relative_to("site").as_posix()
|
|
596
|
+
site_cover = PurePosixPath(record["cover_site_path"]).relative_to("site").as_posix()
|
|
597
|
+
site_pdf_record = site_files.get(site_pdf)
|
|
598
|
+
if site_pdf_record is None or (site_pdf_record["sha256"], site_pdf_record["size"]) != (
|
|
599
|
+
record["sha256"],
|
|
600
|
+
record["size"],
|
|
601
|
+
):
|
|
602
|
+
raise VerificationError(f"Site PDF does not match release-manifest.json: {site_pdf}")
|
|
603
|
+
cover = site_files.get(site_cover)
|
|
604
|
+
if cover is None or (cover["sha256"], cover["size"]) != (record["cover_sha256"], record["cover_size"]):
|
|
605
|
+
raise VerificationError(f"Site cover does not match release-manifest.json: {site_cover}")
|
|
606
|
+
|
|
607
|
+
verify_sums(assets["SHA256SUMS"], assets, expected_asset_names - {"SHA256SUMS"})
|
|
608
|
+
return {
|
|
609
|
+
"asset_count": len(expected_asset_names),
|
|
610
|
+
"candidate_manifest_sha256": args.candidate_manifest_sha256,
|
|
611
|
+
"ok": True,
|
|
612
|
+
"selector": args.selector,
|
|
613
|
+
"site_fingerprint": site_tree["sha256"],
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def parser() -> argparse.ArgumentParser:
|
|
618
|
+
value = argparse.ArgumentParser(description="Verify stable manual release assets against reviewed evidence.")
|
|
619
|
+
value.add_argument("--assets", type=Path, required=True)
|
|
620
|
+
value.add_argument("--selector", required=True)
|
|
621
|
+
value.add_argument("--reviewed-sha", required=True)
|
|
622
|
+
value.add_argument("--core-sha", required=True)
|
|
623
|
+
value.add_argument("--manual-pdf-image", required=True)
|
|
624
|
+
value.add_argument("--python-version", required=True)
|
|
625
|
+
value.add_argument("--ruby-version", required=True)
|
|
626
|
+
value.add_argument("--site-build-image", required=True)
|
|
627
|
+
value.add_argument("--vegavisuals-sha", default="")
|
|
628
|
+
value.add_argument("--repository", required=True)
|
|
629
|
+
value.add_argument("--candidate-manifest-sha256", required=True)
|
|
630
|
+
value.add_argument("--reference-assets", type=Path)
|
|
631
|
+
return value
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def main() -> int:
|
|
635
|
+
args = parser().parse_args()
|
|
636
|
+
try:
|
|
637
|
+
if args.reference_assets is not None:
|
|
638
|
+
compare_asset_directories(args.reference_assets.absolute(), args.assets.absolute())
|
|
639
|
+
result = verify(args)
|
|
640
|
+
if args.reference_assets is not None:
|
|
641
|
+
result["reference_assets_match"] = True
|
|
642
|
+
except (OSError, VerificationError, tarfile.TarError) as exc:
|
|
643
|
+
print(json.dumps({"error": str(exc), "ok": False}, sort_keys=True))
|
|
644
|
+
return 1
|
|
645
|
+
print(json.dumps(result, sort_keys=True))
|
|
646
|
+
return 0
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
if __name__ == "__main__":
|
|
650
|
+
raise SystemExit(main())
|