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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE +21 -0
- data/README.md +66 -0
- data/docs/integration.md +7 -0
- data/docs/manifest.md +11 -0
- data/docs/security.md +7 -0
- data/examples/documents/plan-fixture.txt +1 -0
- data/examples/documents/title-fixture.txt +1 -0
- data/examples/manifest.json +9 -0
- data/exe/property-dossier +25 -0
- data/lib/property_dossier/inspector.rb +67 -0
- data/lib/property_dossier/manifest.rb +64 -0
- data/lib/property_dossier/report.rb +19 -0
- data/lib/property_dossier/version.rb +4 -0
- data/lib/property_dossier.rb +9 -0
- metadata +64 -0
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
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.
|
data/docs/integration.md
ADDED
|
@@ -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,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: []
|