diff --git a/compose.yml b/compose.yml index c27d10f..1a4da3e 100644 --- a/compose.yml +++ b/compose.yml @@ -3,8 +3,11 @@ services: app: build: . + environment: + - MAPS_ROOT=/var/www/html/maps ports: - 8080:8080 volumes: - ./test/conf/cas-vhost.conf:/etc/httpd/conf.d/cas-vhost.conf:ro + - ./test/conf/map-access.py:/usr/local/bin/map-access.py - ./test/data:/var/www/html:ro diff --git a/test/conf/cas-vhost.conf b/test/conf/cas-vhost.conf index b7bf4fa..89a85f2 100644 --- a/test/conf/cas-vhost.conf +++ b/test/conf/cas-vhost.conf @@ -1,3 +1,24 @@ +Mutex file:/var/run/httpd rewrite-map + +RewriteEngine On +RewriteMap mapaccess "prg:/usr/local/bin/map-access.py ${MAPS_ROOT}" www-data:www-data +RewriteCond "${mapaccess:$1}" "=Public" +RewriteRule "^/maps/([^/]+)/data/" - [E=MAP_IS_PUBLIC:1] + +# Only data files are restricted. Directory listings (and metadata) are always +# public so users can browse a map even when they can't download it. + + # mod_dir's index.html subrequest would otherwise trigger the CAS redirect. + DirectoryIndex disabled + AuthType CAS + CASScope /maps + + Require expr -d %{REQUEST_FILENAME} + Require env MAP_IS_PUBLIC + Require valid-user + + + AuthType CAS Require valid-user diff --git a/test/conf/map-access.py b/test/conf/map-access.py new file mode 100755 index 0000000..68b11d4 --- /dev/null +++ b/test/conf/map-access.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 + +""" +Returns +""" + +import json +import os +import re +import sys + +from collections import namedtuple, OrderedDict +from pathlib import Path + + +# mod_rewrite starts prg: maps with an empty environment, so httpd passes the +# root as an argument (expanded from ${MAPS_ROOT} in the config). +MAPS_ROOT = Path(sys.argv[1] if len(sys.argv) > 1 else os.environ.get("MAPS_ROOT", "/srv/geospatial")).resolve() +PUBLIC = b"Public\n" +RESTRICTED = b"Restricted\n" +VALID_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$") +CACHE_MAX = 45_000 + + +class RightsCache: + def __init__(self, max_items=CACHE_MAX): + self._cache = OrderedDict() + self._max_items = max_items + + def set(self, map_id, rights, meta_stat): + key = self._cachekey(meta_stat) + self._cache[map_id] = (key, rights) + self._cache.move_to_end(map_id) + self._prune() + + def get(self, map_id, meta_stat): + cached = self._cache.get(map_id) + if cached and cached[0] == self._cachekey(meta_stat): + self._cache.move_to_end(map_id) + return cached[1] + else: + return None + + def rm(self, map_id): + self._cache.pop(map_id, None) + + def _cachekey(self, meta_stat): + return ( + meta_stat.st_dev, + meta_stat.st_ino, + meta_stat.st_size, + meta_stat.st_mtime_ns, + meta_stat.st_ctime_ns + ) + + def _prune(self): + while len(self._cache) > self._max_items: + self._cache.popitem(last=False) + + +def log(msg): + try: + sys.stderr.write(f"map-access: {msg}\n") + sys.stderr.flush() + except Exception: + pass + + +def get_access_rights(map_id: str, rights_cache: RightsCache) -> bytes: + log(f"Checking map: {map_id}") + + if not VALID_ID.match(map_id): + return RESTRICTED + + geoblacklight_json = Path(MAPS_ROOT / map_id / "metadata" / "geoblacklight.json").resolve() + log(f"Checking geoblacklight.json: {geoblacklight_json}") + + # Guard against symlinks pointing outside the maps tree. + if not geoblacklight_json.is_relative_to(MAPS_ROOT): + log(f"[restricted] path escapes root: {map_id} -> {geoblacklight_json}") + return RESTRICTED + + # Ensure metadata exists + try: + st = geoblacklight_json.stat() + except OSError as e: + log(f"[restricted] error stat-ing {geoblacklight_json}: {e!r}") + rights_cache.rm(map_id) + return RESTRICTED + + if access_rights := rights_cache.get(map_id, st): + return access_rights + + # Read accessRights from geoblacklight.json + try: + with geoblacklight_json.open('rb') as fh: + doc = json.load(fh) + + access_rights = PUBLIC if is_public(doc) else RESTRICTED + log(f"Determined access rights from file: {map_id} -> {access_rights}") + except Exception as e: + log(f"could not read {geoblacklight_json}: {e!r}") + access_rights = RESTRICTED + + rights_cache.set(map_id, access_rights, st) + + return access_rights + + +def is_public(doc): + value = doc.get('dct_accessRights_s', doc.get('dc_rights_s')) + return isinstance(value, str) and value.strip() == "Public" + + +def main(): + stdin = sys.stdin.buffer + stdout = sys.stdout.buffer + rights_cache = RightsCache() + + while True: + line = stdin.readline() + if not line: + return + + try: + map_id = line.decode('utf-8', 'strict').strip() + access_rights = get_access_rights(map_id, rights_cache=rights_cache) + except Exception as e: + log(f"Unexpected error: {e!r}") + access_rights = RESTRICTED + + try: + stdout.write(access_rights) + stdout.flush() + except BrokenPipeError: + return + +if __name__ == "__main__": + main() diff --git a/test/data/maps/berkeley-s76m41/data/original/data.zip b/test/data/maps/berkeley-s76m41/data/original/data.zip new file mode 100644 index 0000000..e69de29 diff --git a/test/data/maps/berkeley-s76m41/metadata/geoblacklight.json b/test/data/maps/berkeley-s76m41/metadata/geoblacklight.json new file mode 100644 index 0000000..708ef55 --- /dev/null +++ b/test/data/maps/berkeley-s76m41/metadata/geoblacklight.json @@ -0,0 +1,30 @@ +{ + "dc_creator_sm": [ + "Geographic Information Systems, Sonoma County (Calif.)., California " + ], + "dc_description_s": "The local area development guideline dataset represents the discreet geographical areas subject to specific development guidelines within Sonoma County. The local area development guidelines and goals are as follows: Canon Manor West Subdivision Development Guideline The boundary polygon represents the Canon Manor West Subdivision Development Guideline, 237+/- acres located southwest of the intersection of Petaluma Hill Road and East Cotati Avenue, adopted by Ordinance #5675 & #5676 on July 18, 2006. The Canon Manor West Subdivision Development Guideline boundary surrounds those parcels subject to water conservation measures as described in Ordinance #5676. To note, Ordinance #5675 outlines the approved rezoning of the subject area [refer to adopted documentation for further insight]. - - - - Glen Ellen Development and Design Guideline, Subarea I & II The boundary polygon represents the Glen Ellen Development and Design Guideline adopted by Ordinance #4297 on November 14, 1990. The Glen Ellen Development and Design Guidelines boundary surrounds those parcels within subareas I and II of the Glen Ellen Study Area. The following applications within the subareas shall be evaluated in accordance with the Glen Ellen Development and Design Guidelines: Subarea I (exterior building permits, discretionary permits, and projects subject to administrative design review), and Subarea II (discretionary permits). The goal of the Glen Ellen Development and Design Guidelines is to direct development so that the character of Glen Ellen is enhanced and maintained. The guideline defines a local area, as a subunit of a planning area, which will provide a greater level of detail for specific uses or development in a local area. The Glen Ellen Development and Design Guidelines represent revised goals, objectives, policies and design guideline contained in previous versions of the Glen Ellen Town Plan (February 1988 and November 1988). - - - - Highway 12 Development Guideline The boundary polygon represents the Highway 12 Development Guideline adopted by Ordinance #5120 on August 25, 1998. The Highway 12 Development Guideline is applicable to parcels having frontage on Highway 12 from its intersection at El Verano Avenue, north, to its intersection at Agua Caliente Road. The Highway 12 Development Guideline is in conjunction with the Sonoma Valley Redevelopment Project Area. The goal of the Highway 12 Development Guideline is to provide criteria for private development to ensure the consistency of each individual project with the overall character of the Hwy 12 corridor. - - - - Highway 116 Scenic Corridor Study Guideline The boundary polygon represents the 116 Scenic Highway Corridor Study Guideline adopted by the State of California's Department of Transportation (Caltrans) on September 20, 1988. The goal of the 116 Scenic Highway Corridor Study is to protect and enhance the corridor. Therefore, the corridor limits are principally determined by topography (land forms) and/or large, dense stands of vegetation limiting views from the highway. If vegetation or man-made structures do not obstruct views from the highway, the farthest ridge lines are the limits of the corridor. Only those ridges visible from the corridor are included; consequently, areas behind ridges are not included. The width of the corridor varies greatly from as little as 100 feet to as much as 14,000 feet. Property within the corridor could be subject to higher levels of planning controls to preserve the scenic quality. - - - - Penngrove Main Street Design Guidelines The boundary polygon represents the Penngrove Main Street Design Guidelines adopted by Ordinance #5920 on December 14, 2010. The goal of the Penngrove Main Street Design Guidelines is to evaluate the merits of projects located in and around Main Street in Penngrove, between Old Redwood Highway and Adobe Road, and as specifically depicted by Figure E of Ordinance # 5920 article. Building permits involved new construction, or the exterior modification of existing structures, are subject to review to ensure conformance with the Penngrove Main Street Design Guidelines. In addition, all projects requiring discretionary entitlements, such as Design Review approval, Zoning Permits, and Use Permits, must comply with the Penngrove Main Street Design Guidelines. - - - - Russian River Corridor Design Guidelines The boundary polygon represents the Russian River Corridor Design Guidelines adopted by Ordinance #5921 on December 14, 2010. The goal of the Russian River Corridor Design Guidelines is to evaluate the merits of projects located within the central corridor which runs through the Russian River Redevelopment Project Area, and as specifically depicted in Figure A of Ordinance #5921 article. The Russian River Corridor Design Guidelines apply to commercial and multi-family residential projects. Single family projects are only subject to the Design Guidelines when involving four or more units. Building permits involving new construction, or any exterior modification of existing structures, are subject to review to ensure conformance with the Russian River Corridor Design Guidelines, as are all projects which require discretionary entitlements such as Design Review, Zoning Permits, and Use Permits. - - - - Sebastopol Road Urban Vision Plan The boundary polygon represents the Sebastopol Road Urban Vision Plan adopted by Resolution #07-0481 on October 31, 2007. The Urban Vision Plan for Sebastopol Road Corridor affects that stretch of Sebastopol Road between Dutton Avenue to the east and Stony Point Road to the west, linking both sides of Sebastopol Road as well as the area north of Sebastopol Road, ending at the Highway 12 right-of-way. The corridor is within the community of Roseland in the southwest area of Santa Rosa, California. While portions of the subject area are within the boundaries of the City of Santa Rosa, the majority of the properties are located in the unincorporated area of Sonoma County. The goal of the Sebastopol Road Urban Vision Plan is to create a consensus among the local community as to what future development of the corridor should look like and what types of uses should be located there. The goal includes land use concept and urban design criteria for the greater planning area, street scape enhancements, and envisioning the Roseland Village Shopping Center and the Dutton Plaza as an ideal location for a neighborhood center. - - - - Taylor/Sonoma/Mayacamas Design Guidelines The boundary polygon represents the Taylor/Sonoma/Mayacamas Design Guidelines adopted by Ordinance #5935 on May 10, 2011. The adoption amends Section 26-90-050 of the Sonoma County code to replace the existing Taylor-Sonoma Mountain Development Guidelines due to following General Plan scenic landscape units. The boundary polygon was revised with Resolution #11-0462 adopted August 23, 2011. The goal of the Taylor-Sonoma Mountain Development Guideline is to reduce the visual impacts of residential related development within the Scenic Landscape Units of Taylor, Sonoma, and Mayacamas areas as visible from public roads.", + "dc_format_s": "Shapefile", + "dc_identifier_s": "http://spatial.lib.berkeley.edu/viewpublic/berkeley-s76m41", + "dc_rights_s": "Public", + "dc_subject_sm": [ + "Boundaries", + "Planningcadastre", + "Development", + "Design" + ], + "dc_title_s": "Local Area Development, Sonoma County, California, 2012", + "dc_type_s": "Dataset", + "dct_issued_dt": "2012-12-01T23:34:35.206Z", + "dct_provenance_s": "Berkeley", + "dct_references_s": "{\"http://www.opengis.net/def/serviceType/ogc/wfs\":\"https://geoservices.lib.berkeley.edu/geoserver/wfs\",\"http://www.opengis.net/def/serviceType/ogc/wms\":\"https://geoservices.lib.berkeley.edu/geoserver/wms\",\"http://www.isotc211.org/schemas/2005/gmd/\":\"https://spatial.lib.berkeley.edu/public/berkeley-s76m41/iso19139.xml\",\"http://schema.org/downloadUrl\":\"https://spatial.lib.berkeley.edu/public/berkeley-s76m41/data.zip\"}", + "dct_spatial_sm": [ + "Sonoma County (Calif.)" + ], + "geoblacklight_version": "1.0", + "layer_geom_type_s": "Polygon", + "layer_id_s": "s76m41", + "layer_modified_dt": "2018-04-02T23:34:35.206Z", + "layer_slug_s": "berkeley-s76m41", + "solr_geom": "ENVELOPE(-123.110182, -122.363334, 38.530869, 38.21665)", + "solr_year_i": 2012 +} diff --git a/test/data/maps/berkeley-s7c980/data/original/data.zip b/test/data/maps/berkeley-s7c980/data/original/data.zip new file mode 100644 index 0000000..e69de29 diff --git a/test/data/maps/berkeley-s7c980/metadata/geoblacklight.json b/test/data/maps/berkeley-s7c980/metadata/geoblacklight.json new file mode 100644 index 0000000..00b3f5f --- /dev/null +++ b/test/data/maps/berkeley-s7c980/metadata/geoblacklight.json @@ -0,0 +1,32 @@ +{ + "dct_creator_sm":["All China Marketing Research Co., Ltd."], + "dct_description_sm":["This polygon dataset represents county boundaries in China from 1989. The attribute table includes the GB codes and names in Chinese and English (Pinyin). All data is copyrighted by China Data Center and/or its suppliers. Derived works that include the source data must be merged with other value-added data in such a way that the derived work cannot be converted back to the original source data format. This data is licensed by UC Berkeley for research, educational, and other non-commercial use by authorized users, which include persons affiliated with UC Berkeley and walk-in users who must access the data in person at the library.The data sources for the layers in the collection \"Administrative Boundary Maps of China, 1949-2014\" include the following primary sources: “The Historical Changes of Administrative Regions of China, 1949-1983” (three volumes); 《中华人民共和国县级以上行政区沿革》(三册,1949-1983年)“The Atlas of Changes of Administrative Regions of China”;《中华人民共和国行政区划沿革地图集》The yearly publications of “The Administrative Regions”, 1985-2014; 每年的出版行政区划简册(1985-2014年)The web site for administrative regions: www.xzqh.org; 行政区划网www.xzqh.org"], + "dct_format_s":"Shapefile", + "dct_identifier_sm":["https://spatial.lib.berkeley.edu/viewpublic/berkeley-s7c980"], + "dct_publisher_sm":["China Data Center, University of Michigan"], + "dct_accessRights_s":"Restricted", + "dct_subject_sm":["Boundaries","Administrative and Political Divisions"], + "dct_title_s":"County Boundaries, China, 1989", + "dct_isPartOf_sm":["berkeley-s7r712"], + "dct_issued_s":"2016-01-01", + "schema_provider_s":"University of California Berkeley", + "dct_references_s":"{\"http://www.opengis.net/def/serviceType/ogc/wfs\":\"https://geoservices-secure.lib.berkeley.edu/geoserver/wfs\",\"http://www.opengis.net/def/serviceType/ogc/wms\":\"https://geoservices-secure.lib.berkeley.edu/geoserver/wms\",\"http://www.isotc211.org/schemas/2005/gmd/\":\"https://spatial.lib.berkeley.edu/metadata/berkeley-s7c980/iso19139.xml\",\"http://schema.org/downloadUrl\":\"https://spatial.lib.berkeley.edu/UCB/berkeley-s7c980/data.zip\"}", + "dct_spatial_sm":["China"], + "gbl_resourceType_sm":["Polygon data","Vector data"], + "gbl_wxsIdentifier_s":"s7c980", + "gbl_mdModified_dt":"2019-06-26T23:34:35.206Z", + "id":"berkeley-s7c980", + "locn_geometry":"ENVELOPE(68.196555, 136.429552, 54.139796, 5.036617)", + "dcat_bbox":"ENVELOPE(68.196555, 136.429552, 54.139796, 5.036617)", + "solr_bboxtype":"ENVELOPE(68.196555, 136.429552, 54.139796, 5.036617)", + "gbl_indexYear_im":[1989], + "gbl_mdVersion_s":"Aardvark", + "gbl_resourceClass_sm":["Datasets"], + "_version_":1870817623581655040, + "timestamp":"2026-07-15T21:25:18.023Z", + "solr_bboxtype__minX":68.196555, + "solr_bboxtype__minY":5.036617, + "solr_bboxtype__maxX":136.429552, + "solr_bboxtype__maxY":54.139796, + "score":4.6867046 +} diff --git a/test/run_tests.sh b/test/run_tests.sh index 441bbb5..9a8410e 100755 --- a/test/run_tests.sh +++ b/test/run_tests.sh @@ -1,37 +1,75 @@ #!/bin/bash app_url="localhost:8080" +cas_login="Location: https://auth.berkeley.edu/cas/" -function test_public { - if curl -s "${app_url}/public/" | grep -q 'The Regents of the University of California'; then - echo "PASS: /public/ serves custom dynamic index page" +# Restricted per metadata (dct_accessRights_s) and public (legacy dc_rights_s) +restricted_map="/maps/berkeley-s7c980" +public_map="/maps/berkeley-s76m41" + +function check { + local desc="$1"; shift + if "$@"; then + echo "PASS: ${desc}" else - echo "FAILED: /public/ serves custom dynamic index page" + echo "FAILED: ${desc}" return 1 fi } +function status_is { + [ "$(curl -s -o /dev/null -w '%{http_code}' "${app_url}$1")" = "$2" ] +} + +function redirects_to_cas { + curl -sI "${app_url}$1" | grep -q "${cas_login}" +} + +function lists { + curl -s "${app_url}$1" | grep -q "href=\"$2\"" +} + +function test_public { + check "/public/ serves custom dynamic index page" \ + sh -c "curl -s '${app_url}/public/' | grep -q 'The Regents of the University of California'" +} + function test_ucb { - if curl -sI "${app_url}/UCB/" | grep -q "Location: https://auth.berkeley.edu/cas/"; then - echo "PASS: /UCB/ redirects to auth.berkeley.edu" + check "/UCB/ redirects to auth.berkeley.edu" redirects_to_cas /UCB/ +} + +function test_maps { + local rc=0 + check "/maps/ lists maps" lists /maps/ berkeley-s7c980/ || rc=1 + check "restricted map: metadata is public" status_is "${restricted_map}/metadata/geoblacklight.json" 200 || rc=1 + check "restricted map: data/ listing is public" lists "${restricted_map}/data/" original/ || rc=1 + check "restricted map: nested listing shows files" lists "${restricted_map}/data/original/" data.zip || rc=1 + check "restricted map: data file requires CAS" redirects_to_cas "${restricted_map}/data/original/data.zip" || rc=1 + check "restricted map: MultiViews can't bypass CAS" redirects_to_cas "${restricted_map}/data/original/data" || rc=1 + check "public map: data file is served" status_is "${public_map}/data/original/data.zip" 200 || rc=1 + check "public map: repeat request (cached) is served" status_is "${public_map}/data/original/data.zip" 200 || rc=1 + check "unknown map: data path requires CAS" redirects_to_cas "/maps/nonexistent/data/x.zip" || rc=1 + return $rc +} + +function test_map_access_unit { + # Run as www-data (like httpd does) so permission tests aren't skipped for root. + if docker compose run --rm --no-deps -T -u www-data -v "$PWD/test:/test:ro" app \ + python3 -m unittest discover -s /test/unit; then + echo "PASS: map-access.py unit tests" else - echo "FAILED: /UCB/ redirects to auth.berkeley.edu" + echo "FAILED: map-access.py unit tests" return 1 fi } function run_tests { - test_public & - test1=$! - test_ucb & - test2=$! - - wait $test1 - res1=$? - wait $test2 - res2=$? - - [ "$res1" -eq "0" -a "$res2" -eq "0" ] || exit 1 + local rc=0 + test_public || rc=1 + test_ucb || rc=1 + test_maps || rc=1 + test_map_access_unit || rc=1 + return $rc } function stackup { @@ -46,7 +84,7 @@ function stackdown { function main { trap stackdown EXIT stackup - run_tests + run_tests || exit 1 } main diff --git a/test/unit/test_map_access.py b/test/unit/test_map_access.py new file mode 100644 index 0000000..9236eb6 --- /dev/null +++ b/test/unit/test_map_access.py @@ -0,0 +1,273 @@ +"""Unit tests for test/conf/map-access.py (the RewriteMap prg: program).""" + +import importlib.util +import io +import json +import os +import subprocess +import sys +import tempfile +import unittest + +from contextlib import redirect_stderr +from pathlib import Path +from unittest import mock + +SCRIPT = Path(__file__).resolve().parent.parent / "conf" / "map-access.py" + +# The script's filename has a hyphen, so it can't be imported normally. +_spec = importlib.util.spec_from_file_location("map_access", SCRIPT) +map_access = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(map_access) + +PUBLIC = map_access.PUBLIC +RESTRICTED = map_access.RESTRICTED + + +class MapsTestCase(unittest.TestCase): + """Builds a throwaway maps root and points the module at it.""" + + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name).resolve() / "maps" + self.root.mkdir() + + patcher = mock.patch.object(map_access, "MAPS_ROOT", self.root) + patcher.start() + self.addCleanup(patcher.stop) + + # Keep test output quiet; the script logs every lookup to stderr. + stderr = redirect_stderr(io.StringIO()) + stderr.__enter__() + self.addCleanup(stderr.__exit__, None, None, None) + + self.cache = map_access.RightsCache() + + def write_map(self, map_id, doc): + meta = self.root / map_id / "metadata" + meta.mkdir(parents=True, exist_ok=True) + path = meta / "geoblacklight.json" + path.write_text(doc if isinstance(doc, str) else json.dumps(doc)) + return path + + def rights(self, map_id): + return map_access.get_access_rights(map_id, self.cache) + + +class RightsCacheTest(MapsTestCase): + def setUp(self): + super().setUp() + self.path = self.write_map("m", {"dct_accessRights_s": "Public"}) + + def test_miss_returns_none(self): + self.assertIsNone(self.cache.get("m", self.path.stat())) + + def test_hit_returns_rights(self): + self.cache.set("m", PUBLIC, self.path.stat()) + self.assertEqual(self.cache.get("m", self.path.stat()), PUBLIC) + + def test_rm(self): + self.cache.set("m", PUBLIC, self.path.stat()) + self.cache.rm("m") + self.cache.rm("never-cached") # no error + self.assertIsNone(self.cache.get("m", self.path.stat())) + + def test_stale_after_content_change(self): + self.cache.set("m", PUBLIC, self.path.stat()) + self.path.write_text(json.dumps({"dct_accessRights_s": "Restricted"})) + self.assertIsNone(self.cache.get("m", self.path.stat())) + + def test_stale_after_same_size_write_with_mtime_restored(self): + # e.g. rsync --inplace -t: same size, same mtime, new content. + before = self.path.stat() + self.cache.set("m", PUBLIC, before) + self.path.write_text(self.path.read_text().replace("Public", "Pxblic")) + os.utime(self.path, ns=(before.st_atime_ns, before.st_mtime_ns)) + after = self.path.stat() + self.assertEqual((after.st_size, after.st_mtime_ns), (before.st_size, before.st_mtime_ns)) + self.assertIsNone(self.cache.get("m", after)) + + def test_stale_after_chmod(self): + self.cache.set("m", RESTRICTED, self.path.stat()) + self.path.chmod(0o600) + self.assertIsNone(self.cache.get("m", self.path.stat())) + + def test_stale_after_atomic_replace(self): + self.cache.set("m", PUBLIC, self.path.stat()) + tmp = self.path.with_suffix(".tmp") + tmp.write_text(self.path.read_text()) + tmp.replace(self.path) + self.assertIsNone(self.cache.get("m", self.path.stat())) + + def test_evicts_least_recently_used(self): + cache = map_access.RightsCache(max_items=2) + st = self.path.stat() + cache.set("a", PUBLIC, st) + cache.set("b", PUBLIC, st) + cache.get("a", st) # a is now most recently used + cache.set("c", PUBLIC, st) # evicts b + self.assertEqual(cache.get("a", st), PUBLIC) + self.assertIsNone(cache.get("b", st)) + self.assertEqual(cache.get("c", st), PUBLIC) + + def test_set_existing_key_refreshes_recency(self): + cache = map_access.RightsCache(max_items=2) + st = self.path.stat() + cache.set("a", PUBLIC, st) + cache.set("b", PUBLIC, st) + cache.set("a", RESTRICTED, st) # update a, making b the oldest + cache.set("c", PUBLIC, st) + self.assertEqual(cache.get("a", st), RESTRICTED) + self.assertIsNone(cache.get("b", st)) + + def test_never_exceeds_max_items(self): + cache = map_access.RightsCache(max_items=3) + st = self.path.stat() + for i in range(10): + cache.set(f"m{i}", PUBLIC, st) + self.assertEqual(len(cache._cache), 3) + + +class AccessRightsTest(MapsTestCase): + def test_dct_access_rights_public(self): + self.write_map("m", {"dct_accessRights_s": "Public"}) + self.assertEqual(self.rights("m"), PUBLIC) + + def test_dct_access_rights_restricted(self): + self.write_map("m", {"dct_accessRights_s": "Restricted"}) + self.assertEqual(self.rights("m"), RESTRICTED) + + def test_dc_rights_fallback(self): + self.write_map("m", {"dc_rights_s": "Public"}) + self.assertEqual(self.rights("m"), PUBLIC) + + def test_dct_access_rights_takes_precedence(self): + self.write_map("m", {"dct_accessRights_s": "Restricted", "dc_rights_s": "Public"}) + self.assertEqual(self.rights("m"), RESTRICTED) + + def test_surrounding_whitespace_is_ignored(self): + self.write_map("m", {"dct_accessRights_s": " Public\n"}) + self.assertEqual(self.rights("m"), PUBLIC) + + def test_restricted_values(self): + for doc in [ + {}, + {"dct_accessRights_s": None}, + {"dct_accessRights_s": "public"}, + {"dct_accessRights_s": ["Public"]}, + {"dct_accessRights_s": True}, + [], + "not json", + "", + ]: + with self.subTest(doc=doc): + self.cache = map_access.RightsCache() + self.write_map("m", doc) + self.assertEqual(self.rights("m"), RESTRICTED) + + def test_invalid_ids(self): + self.write_map("m", {"dct_accessRights_s": "Public"}) + for map_id in ["", ".", "..", ".m", "-m", "m/x", "../maps/m", "m\x00", "m m", "a" * 256]: + with self.subTest(map_id=map_id): + self.assertEqual(self.rights(map_id), RESTRICTED) + + def test_longest_valid_id(self): + self.write_map("a" * 255, {"dct_accessRights_s": "Public"}) + self.assertEqual(self.rights("a" * 255), PUBLIC) + + def test_missing_metadata(self): + (self.root / "m").mkdir() + self.assertEqual(self.rights("m"), RESTRICTED) + self.assertEqual(self.rights("nonexistent"), RESTRICTED) + + def test_symlink_escaping_root(self): + outside = self.root.parent / "outside" + outside.mkdir() + (outside / "metadata").mkdir() + (outside / "metadata" / "geoblacklight.json").write_text('{"dct_accessRights_s": "Public"}') + (self.root / "m").symlink_to(outside) + self.assertEqual(self.rights("m"), RESTRICTED) + + def test_symlink_within_root(self): + self.write_map("real", {"dct_accessRights_s": "Public"}) + (self.root / "alias").symlink_to(self.root / "real") + self.assertEqual(self.rights("alias"), PUBLIC) + + +class CachingTest(MapsTestCase): + def test_second_lookup_is_served_from_cache(self): + self.write_map("m", {"dct_accessRights_s": "Public"}) + self.assertEqual(self.rights("m"), PUBLIC) + with mock.patch.object(map_access.json, "load", side_effect=AssertionError("re-read")): + self.assertEqual(self.rights("m"), PUBLIC) + + def test_metadata_change_is_picked_up(self): + path = self.write_map("m", {"dct_accessRights_s": "Public"}) + self.assertEqual(self.rights("m"), PUBLIC) + path.write_text(json.dumps({"dct_accessRights_s": "Restricted"})) + self.assertEqual(self.rights("m"), RESTRICTED) + + def test_deleted_metadata_is_restricted_and_evicted(self): + path = self.write_map("m", {"dct_accessRights_s": "Public"}) + self.assertEqual(self.rights("m"), PUBLIC) + path.unlink() + self.assertEqual(self.rights("m"), RESTRICTED) + self.assertNotIn("m", self.cache._cache) + + def test_parse_error_recovers_once_file_is_fixed(self): + path = self.write_map("m", '{"dct_accessRights_s": "Pub') + self.assertEqual(self.rights("m"), RESTRICTED) + path.write_text(json.dumps({"dct_accessRights_s": "Public"})) + self.assertEqual(self.rights("m"), PUBLIC) + + @unittest.skipIf(os.geteuid() == 0, "root ignores file permissions") + def test_permission_error_recovers_after_chmod(self): + path = self.write_map("m", {"dct_accessRights_s": "Public"}) + path.chmod(0o000) + self.addCleanup(path.chmod, 0o644) + self.assertEqual(self.rights("m"), RESTRICTED) + path.chmod(0o644) + self.assertEqual(self.rights("m"), PUBLIC) + + @unittest.skipIf(os.geteuid() == 0, "root ignores file permissions") + def test_unreadable_metadata_dir_is_restricted_and_evicted(self): + path = self.write_map("m", {"dct_accessRights_s": "Public"}) + self.assertEqual(self.rights("m"), PUBLIC) + path.parent.chmod(0o000) + self.addCleanup(path.parent.chmod, 0o755) + self.assertEqual(self.rights("m"), RESTRICTED) + self.assertNotIn("m", self.cache._cache) + + +class ProtocolTest(unittest.TestCase): + """Runs the script the way mod_rewrite does: root as argv[1], empty env.""" + + def test_one_answer_per_line(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "maps" + for map_id, rights in [("pub", "Public"), ("res", "Restricted")]: + (root / map_id / "metadata").mkdir(parents=True) + (root / map_id / "metadata" / "geoblacklight.json").write_text( + json.dumps({"dct_accessRights_s": rights})) + + requests = [b"pub", b"res", b"missing", b"../etc", b"\xff\xfe", b"", b"pub"] + proc = subprocess.run( + [sys.executable, str(SCRIPT), str(root)], + input=b"".join(r + b"\n" for r in requests), + capture_output=True, + env={}, + timeout=10, + ) + + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(proc.stdout.splitlines(), [ + b"Public", b"Restricted", b"Restricted", b"Restricted", + b"Restricted", b"Restricted", b"Public", + ]) + # The cache must persist across lines: "pub" is read from disk only once. + self.assertEqual(proc.stderr.count(b"Determined access rights from file: pub"), 1, proc.stderr) + + +if __name__ == "__main__": + unittest.main()