property_dossier 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3bdbdbf29a19a497a1f8faca41696d5ef802a5bcee793b1b4e2e3d13a7763e52
4
+ data.tar.gz: 1b52bdb8bbb3137b7b5ac90af9f8e48cb0806fb2a9a6821a03e19f73c60dd6a6
5
+ SHA512:
6
+ metadata.gz: 471c8fc2fb5f64b3965d8fbc416421dba39f8b0be6141431b0a5e70307a94a5a69e53499f172f72808eafccac2c9e82645a23d57f47b5a5ebcba502cd07aae1b
7
+ data.tar.gz: 8307f8b557e61766cff5b99900b5c4d3ad5ec53b5f7988e5217af571b0dce56aa1887efaf4a11f69f713f64d16940d9d0fd2fd801938b2b4b34fb191493b3976
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ First release: strict local manifest, revision-chain review, optional SHA-256 verification, explicit expiry checks and escaped standalone reports. No network or document-content interpretation.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NovAsia Estate
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # Property Dossier
2
+
3
+ An offline document-pack inspector for people handing property files from one team to another. It answers three concrete questions: **is a required file present and readable, have its bytes changed, and which revision is active?**
4
+
5
+ It does not read the contents of a contract, establish legal title, authenticate a signature or decide which documents a jurisdiction requires. Your team supplies its own checklist. No uploads, telemetry, API key, paid account or runtime gems.
6
+
7
+ ## Five-minute example
8
+
9
+ Ruby 3.1 or later:
10
+
11
+ ```sh
12
+ gem install property_dossier
13
+ property-dossier --manifest examples/manifest.json --root examples/documents --as-of 2026-11-06
14
+ property-dossier --manifest examples/manifest.json --root examples/documents --as-of 2026-11-06 --format html > review.html
15
+ ```
16
+
17
+ The example intentionally includes a missing inspection report. Exit 1 and `missing_requirement` are expected. All sample documents are invented plain-text fixtures, never client or legal documents. Download the [source examples](https://github.com/novasia-estate/property-dossier/tree/main/examples) or use those included in the installed gem directory (`gem which property_dossier`).
18
+
19
+ For the library:
20
+
21
+ ```ruby
22
+ require 'property_dossier'
23
+ manifest = PropertyDossier::Manifest.load('manifest.json')
24
+ report = PropertyDossier::Inspector.new(manifest, root: './documents', as_of: '2026-11-06').call
25
+ puts report.to_json
26
+ puts report.ok? # false if any error; warnings alone do not fail
27
+ ```
28
+
29
+ ## What an inspection catches
30
+
31
+ * Required document kinds with no usable active document.
32
+ * Missing, unreadable, empty or non-file paths.
33
+ * SHA-256 mismatches when an expected digest is supplied.
34
+ * Expired documents relative to an explicit date (expiry day itself is valid).
35
+ * Unresolved competing revisions and broken supersession references.
36
+ * Identical content under different names (warning).
37
+ * Paths that escape the root, including resolved symbolic links.
38
+
39
+ No automatic 'latest filename wins'. A readable replacement must explicitly supersede a lower revision of the same kind. A broken replacement is still reported and does not hide an otherwise usable prior file.
40
+
41
+ ## A practical handover
42
+
43
+ 1. Agree the required document kinds with the receiving professional.
44
+ 2. Copy the documents into a dedicated local folder you trust.
45
+ 3. Assign stable IDs, revisions and optional expiry dates in the manifest.
46
+ 4. Record digests at the point you actually receive a file.
47
+ 5. Run an inspection using a fixed date; resolve errors and review warnings.
48
+ 6. Transfer the manifest and files using your organisation's approved channel.
49
+ 7. Re-run at receipt. A matching digest establishes byte identity, not authenticity.
50
+
51
+ The tool never edits source documents, silently refreshes expiry dates or infers a country's legal requirements. Output includes document paths: treat reports as sensitive if paths reveal client information.
52
+
53
+ ## Documentation
54
+
55
+ * [Manifest contract](docs/manifest.md)
56
+ * [Threat model and limits](docs/security.md)
57
+ * [Integration and exit statuses](docs/integration.md)
58
+ * [Changelog](CHANGELOG.md)
59
+
60
+ Run `ruby -Ilib test/test_property_dossier.rb`. The suite uses temporary synthetic files and exercises invalid revisions, traversal, symlink escapes, expiry, tampering and HTML escaping. Build with `gem build property_dossier.gemspec`.
61
+
62
+ ## Maintainer
63
+
64
+ Developed by [NovAsia Estate](https://novasia.estate/), a publisher of property guides and research for Cambodia, Thailand and Bali. This general-purpose tool contains no market offers, legal rules or regional tax assumptions.
65
+
66
+ For the next JSON-schema validation step, see the separate [Property Schema Validator](https://github.com/novasia-estate/property-schema-validator). That tool checks records; this one checks a local document pack. MIT licensed. Reproducible bug reports without client files are welcome.
@@ -0,0 +1,7 @@
1
+ # Integration
2
+
3
+ CLI exit statuses: 0 = no errors; 1 = inspection completed with findings requiring review; 2 = invocation, manifest or root error. Warnings alone leave exit status 0. JSON includes schema version, explicit inspection date, property ID, original document metadata, computed hashes, usability and machine-readable finding codes.
4
+
5
+ Redirect standard output to keep reports. Errors in invocation go to standard error. HTML is a standalone human-readable rendering of the same report. There is no automatic reporting upload.
6
+
7
+ For CI, keep fixtures synthetic and run with a fixed date. An optional checksum is meaningful only if recorded through a trustworthy receipt process. To create one locally: `ruby -rdigest -e 'puts Digest::SHA256.file(ARGV[0]).hexdigest' path/to/file`.
data/docs/manifest.md ADDED
@@ -0,0 +1,11 @@
1
+ # Manifest v1
2
+
3
+ The manifest is UTF-8 JSON with `schema_version: 1`, a nonempty `property_id`, unique `requirements` document-kind strings and a `documents` array. Requirements are supplied by the user and are not a statutory checklist.
4
+
5
+ Each document needs `id`, `kind`, forward-slash relative `path`, and positive integer `revision`. Optional fields: `sha256` (64 lowercase hexadecimal characters), `expires_on` (strict YYYY-MM-DD calendar date), `supersedes` (another document ID of the same kind with a lower revision). IDs and paths are unique. Unknown fields are retained in the report for your own descriptive metadata; do not put secrets there.
6
+
7
+ Absolute paths, backslashes, drive letters, empty segments, `.` and `..` segments are rejected. The inspector resolves file paths and rejects escapes through symbolic links. Files should remain immutable while inspection runs.
8
+
9
+ A supersession chain must increase revision numbers. This also prevents cycles. Multiple unrelated active documents of one kind produce an error even if revision numbers differ. Split document kinds when several distinct files are genuinely required (for example `floor_plan` and `site_plan`).
10
+
11
+ Errors prevent a clean report. Duplicate bytes are warnings because a legitimate translation placeholder or duplicate receipt may need human review rather than automatic rejection. Dates are evaluated as calendar dates; the tool does not infer time zones or statutory grace periods.
data/docs/security.md ADDED
@@ -0,0 +1,7 @@
1
+ # Security boundary
2
+
3
+ Run only on a dedicated folder and manifest you are authorised to inspect. The program does not execute files, extract archives, parse PDFs, fetch URLs, send telemetry or modify documents. HTML reports escape text and include no external scripts or resources.
4
+
5
+ File hashes attest only to bytes observed locally. A forged document can have a valid checksum. File existence, expiry metadata and a complete checklist do not establish legal validity, signer identity, ownership, availability or freedom from encumbrances.
6
+
7
+ The root folder must not be changed by an untrusted concurrent process. Path resolution followed by file reading is not an operating-system sandbox and does not protect against hostile races. Special files are rejected; a large ordinary file may still take time to hash. Reports disclose names, manifest metadata and hashes; keep them under the same access controls as the pack. Do not upload client files in bug reports.
@@ -0,0 +1 @@
1
+ SYNTHETIC TEST FILE. Example floor plan placeholder, no real address or measurements.
@@ -0,0 +1 @@
1
+ SYNTHETIC TEST FILE. Not a title deed, legal document or statement of ownership.
@@ -0,0 +1,9 @@
1
+ {
2
+ "schema_version": 1,
3
+ "property_id": "SYNTHETIC-UNIT-A12",
4
+ "requirements": ["title_copy", "floor_plan", "inspection_report"],
5
+ "documents": [
6
+ {"id": "title-v1", "kind": "title_copy", "path": "title-fixture.txt", "revision": 1},
7
+ {"id": "plan-v1", "kind": "floor_plan", "path": "plan-fixture.txt", "revision": 1}
8
+ ]
9
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ require 'optparse'
4
+ require_relative '../lib/property_dossier'
5
+ options = { format: 'json' }
6
+ parser = OptionParser.new do |p|
7
+ p.banner = 'Usage: property-dossier --manifest manifest.json --root ./documents --as-of YYYY-MM-DD [--format json|html]'
8
+ p.on('--manifest PATH') { |v| options[:manifest] = v }
9
+ p.on('--root PATH') { |v| options[:root] = v }
10
+ p.on('--as-of DATE') { |v| options[:as_of] = v }
11
+ p.on('--format FORMAT', %w[json html]) { |v| options[:format] = v }
12
+ p.on('--version') { puts PropertyDossier::VERSION; exit }
13
+ p.on('-h', '--help') { puts p; exit }
14
+ end
15
+ begin
16
+ parser.parse!
17
+ raise ArgumentError, 'unexpected positional arguments' unless ARGV.empty?
18
+ %i[manifest root as_of].each { |k| raise ArgumentError, "--#{k.to_s.tr('_', '-')} is required" unless options[k] }
19
+ report = PropertyDossier::Inspector.new(PropertyDossier::Manifest.load(options[:manifest]), root: options[:root], as_of: options[:as_of]).call
20
+ puts(options[:format] == 'html' ? report.to_html : report.to_json)
21
+ exit(report.ok? ? 0 : 1)
22
+ rescue OptionParser::ParseError, ArgumentError, PropertyDossier::Manifest::Invalid, SystemCallError => e
23
+ warn "property-dossier: #{e.message}"
24
+ exit 2
25
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+ module PropertyDossier
3
+ class Inspector
4
+ def initialize(manifest, root:, as_of:)
5
+ @manifest = manifest
6
+ @root = File.realpath(root)
7
+ raise ArgumentError, 'root must be a directory' unless File.directory?(@root)
8
+ @as_of = Date.iso8601(as_of)
9
+ @findings = []
10
+ end
11
+
12
+ def call
13
+ @findings = []
14
+ rows = @manifest.documents.map { |d| inspect_document(d) }
15
+ superseded = @manifest.documents.filter_map { |d| d['supersedes'] if rows.find { |r| r['id'] == d['id'] }['usable'] }
16
+ active = rows.select { |r| r['usable'] && !superseded.include?(r['id']) }
17
+ @manifest.requirements.each do |kind|
18
+ finding('missing_requirement', kind, 'No readable, current, checksum-valid active document satisfies this kind.') unless active.any? { |r| r['kind'] == kind }
19
+ end
20
+ active.group_by { |r| r['kind'] }.each do |kind, group|
21
+ finding('unresolved_versions', kind, 'Several active documents exist; explicitly supersede older versions.') if group.size > 1
22
+ end
23
+ rows.select { |r| r['actual_sha256'] }.group_by { |r| r['actual_sha256'] }.each_value do |group|
24
+ finding('duplicate_content', group.map { |r| r['id'] }.join(', '), 'Identical bytes are referenced more than once.', 'warning') if group.size > 1
25
+ end
26
+ Report.new(@manifest.property_id, @as_of.iso8601, rows, @findings)
27
+ end
28
+
29
+ private
30
+ def inspect_document(doc)
31
+ row = doc.merge('usable' => false)
32
+ path = File.join(@root, doc['path'])
33
+ unless File.exist?(path)
34
+ finding('missing_file', doc['id'], 'File does not exist.'); return row
35
+ end
36
+ resolved = File.realpath(path)
37
+ root_path = Pathname.new(@root)
38
+ relative = Pathname.new(resolved).relative_path_from(root_path).to_s
39
+ if relative == '..' || relative.start_with?("..#{File::SEPARATOR}") || Pathname.new(relative).absolute?
40
+ finding('outside_root', doc['id'], 'Resolved path escapes the dossier root.'); return row
41
+ end
42
+ unless File.file?(resolved)
43
+ finding('not_a_file', doc['id'], 'Path is not a regular file.'); return row
44
+ end
45
+ row['bytes'] = File.size(resolved)
46
+ row['actual_sha256'] = Digest::SHA256.file(resolved).hexdigest
47
+ valid = true
48
+ if row['bytes'].zero?
49
+ finding('empty_file', doc['id'], 'Empty documents do not satisfy requirements.'); valid = false
50
+ end
51
+ if doc['sha256'] && doc['sha256'] != row['actual_sha256']
52
+ finding('checksum_mismatch', doc['id'], 'Bytes differ from the manifest checksum.'); valid = false
53
+ end
54
+ if doc['expires_on'] && Date.iso8601(doc['expires_on']) < @as_of
55
+ finding('expired', doc['id'], 'Document expiry precedes the explicit inspection date.'); valid = false
56
+ end
57
+ row['usable'] = valid
58
+ row
59
+ rescue SystemCallError, ArgumentError => e
60
+ finding('unreadable', doc['id'], e.message); row
61
+ end
62
+
63
+ def finding(code, subject, message, severity = 'error')
64
+ @findings << { 'code' => code, 'subject' => subject, 'severity' => severity, 'message' => message }
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+ module PropertyDossier
3
+ # Strict, portable manifest. File names refer to a local root, never remote URLs.
4
+ class Manifest
5
+ class Invalid < StandardError; end
6
+ attr_reader :property_id, :requirements, :documents
7
+
8
+ def self.load(path)
9
+ new(JSON.parse(File.read(path, encoding: 'UTF-8')))
10
+ rescue JSON::ParserError => e
11
+ raise Invalid, "Invalid JSON: #{e.message}"
12
+ end
13
+
14
+ def initialize(data)
15
+ fail!('manifest must be an object') unless data.is_a?(Hash)
16
+ fail!('schema_version must be 1') unless data['schema_version'] == 1
17
+ @property_id = text!(data['property_id'], 'property_id')
18
+ @requirements = data.fetch('requirements', [])
19
+ fail!('requirements must be unique strings') unless @requirements.is_a?(Array) && @requirements.all? { |r| r.is_a?(String) && !r.strip.empty? } && @requirements.uniq == @requirements
20
+ @documents = data['documents']
21
+ fail!('documents must be an array') unless @documents.is_a?(Array)
22
+ ids = []
23
+ paths = []
24
+ @documents.each do |d|
25
+ fail!('each document must be an object') unless d.is_a?(Hash)
26
+ id = text!(d['id'], 'document.id')
27
+ fail!("duplicate document id: #{id}") if ids.include?(id)
28
+ ids << id
29
+ text!(d['kind'], "#{id}.kind")
30
+ path = text!(d['path'], "#{id}.path")
31
+ fail!("unsafe path: #{path}") if path.include?('\\') || path.include?(':') || path.start_with?('/') || path.split('/').any? { |p| p.empty? || ['.', '..'].include?(p) }
32
+ fail!("duplicate document path: #{path}") if paths.include?(path)
33
+ paths << path
34
+ revision = d['revision']
35
+ fail!("#{id}.revision must be a positive integer") unless revision.is_a?(Integer) && revision.positive?
36
+ fail!("#{id}.sha256 must be lowercase hex") if d.key?('sha256') && !(d['sha256'].is_a?(String) && d['sha256'].match?(/\A[0-9a-f]{64}\z/))
37
+ fail!("#{id}.supersedes must reference a different document") if d.key?('supersedes') && (!d['supersedes'].is_a?(String) || d['supersedes'] == id)
38
+ next unless d.key?('expires_on')
39
+ value = d['expires_on']
40
+ begin
41
+ fail!("#{id}.expires_on must be YYYY-MM-DD") unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
42
+ Date.iso8601(value)
43
+ rescue Date::Error
44
+ fail!("#{id}.expires_on is not a calendar date")
45
+ end
46
+ end
47
+ @documents.each do |d|
48
+ next unless d['supersedes']
49
+ prior = @documents.find { |p| p['id'] == d['supersedes'] }
50
+ fail!("unknown superseded document: #{d['supersedes']}") unless prior
51
+ fail!('supersession must preserve kind and increase revision') unless prior['kind'] == d['kind'] && prior['revision'] < d['revision']
52
+ end
53
+ end
54
+
55
+ private
56
+ def fail!(message)
57
+ raise Invalid, message
58
+ end
59
+ def text!(value, field)
60
+ fail!("#{field} must be nonempty text") unless value.is_a?(String) && !value.strip.empty?
61
+ value
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+ require 'cgi'
3
+ module PropertyDossier
4
+ class Report
5
+ attr_reader :data
6
+ def initialize(property_id, as_of, documents, findings)
7
+ @data = { 'schema_version' => 1, 'property_id' => property_id, 'as_of' => as_of,
8
+ 'ok' => findings.none? { |f| f['severity'] == 'error' },
9
+ 'documents' => documents, 'findings' => findings }
10
+ end
11
+ def ok? = @data['ok']
12
+ def to_json(*) = JSON.pretty_generate(@data)
13
+ def to_html
14
+ esc = ->(s) { CGI.escapeHTML(s.to_s) }
15
+ rows = @data['findings'].map { |f| '<tr>' + %w[severity code subject message].map { |k| "<td>#{esc.call(f[k])}</td>" }.join + '</tr>' }.join
16
+ "<!doctype html><html lang=\"en\"><meta charset=\"utf-8\"><title>Dossier inspection</title><style>body{font:16px system-ui;max-width:1100px;margin:3rem auto;padding:1rem}td,th{padding:.6rem;border:1px solid #ccc;text-align:left}table{border-collapse:collapse}code{overflow-wrap:anywhere}</style><h1>#{esc.call(@data['property_id'])}</h1><p>As of #{esc.call(@data['as_of'])}: #{ok? ? 'PASS' : 'REVIEW REQUIRED'}</p><p>File completeness and integrity only. This report does not authenticate documents or certify ownership.</p><table><tr><th>Severity</th><th>Code</th><th>Subject</th><th>Explanation</th></tr>#{rows}</table><h2>Machine-readable report</h2><pre><code>#{esc.call(to_json)}</code></pre></html>"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module PropertyDossier
3
+ VERSION = '1.0.0'
4
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+ require 'json'
3
+ require 'date'
4
+ require 'digest'
5
+ require 'pathname'
6
+ require_relative 'property_dossier/version'
7
+ require_relative 'property_dossier/manifest'
8
+ require_relative 'property_dossier/inspector'
9
+ require_relative 'property_dossier/report'
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: property_dossier
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - NovAsia Estate
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Offline, dependency-free Ruby library and CLI. Explicit manifest requirements,
13
+ revision chains, expiry checks, SHA-256 verification and escaped HTML/JSON reports.
14
+ Does not authenticate documents or determine legal title.
15
+ email:
16
+ - info@novasia.estate
17
+ executables:
18
+ - property-dossier
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - CHANGELOG.md
23
+ - LICENSE
24
+ - README.md
25
+ - docs/integration.md
26
+ - docs/manifest.md
27
+ - docs/security.md
28
+ - examples/documents/plan-fixture.txt
29
+ - examples/documents/title-fixture.txt
30
+ - examples/manifest.json
31
+ - exe/property-dossier
32
+ - lib/property_dossier.rb
33
+ - lib/property_dossier/inspector.rb
34
+ - lib/property_dossier/manifest.rb
35
+ - lib/property_dossier/report.rb
36
+ - lib/property_dossier/version.rb
37
+ homepage: https://novasia.estate/
38
+ licenses:
39
+ - MIT
40
+ metadata:
41
+ homepage_uri: https://novasia.estate/
42
+ source_code_uri: https://github.com/novasia-estate/property-dossier
43
+ bug_tracker_uri: https://github.com/novasia-estate/property-dossier/issues
44
+ documentation_uri: https://github.com/novasia-estate/property-dossier/blob/main/docs/manifest.md
45
+ changelog_uri: https://github.com/novasia-estate/property-dossier/blob/main/CHANGELOG.md
46
+ rdoc_options: []
47
+ require_paths:
48
+ - lib
49
+ required_ruby_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '3.1'
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ requirements: []
60
+ rubygems_version: 3.6.9
61
+ specification_version: 4
62
+ summary: Inspect property document packs for completeness, conflicting revisions and
63
+ file integrity.
64
+ test_files: []