webloc 0.2.0 → 0.3.1
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 +4 -4
- data/.gitignore +1 -0
- data/README.md +103 -3
- data/lib/webloc/version.rb +1 -1
- data/lib/webloc.rb +95 -13
- data/test/webloc_test.rb +89 -1
- metadata +3 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c818dbf002be73a6bcf36920bfae42c98bd5185d8076e5835773f3b08f41fc69
|
4
|
+
data.tar.gz: 78982a897fce013b29b15e66a64cdd003de478040d6399245942fd49858383e0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 659e74f401a6ce48cac21e72bcf0b07e1c2ac2214579ed700d7715efbff9d68da082a043a5036193cd7e792d7f3a8a38a2ef6ac2780951129f1aa37781e74e8f
|
7
|
+
data.tar.gz: 5799dbc3570c7395545a710f2f2f8bc4bb6f5cb79a681c4a9cb862647a489070fbdc35932a474d2fdac5253f9a090087da179cfe4e6365e32a1eb5efa26d5c0f
|
data/.gitignore
CHANGED
data/README.md
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
*webloc* is a Ruby library that can read from and write to <tt>.webloc</tt> files as used on macOS. These are a variant of 'plist' format files, specifically used for storing links to URLs.
|
4
4
|
|
5
|
-
It works on Ruby 2.7 and up, including Ruby 3.x, and supports URLs of up to
|
5
|
+
It works on Ruby 2.7 and up, including Ruby 3.x, and supports URLs of up to 2048 characters in length (and probably longer, but this is around the de facto limit for URLs in most systems).
|
6
6
|
|
7
7
|
## Installation
|
8
8
|
|
@@ -10,14 +10,114 @@ It works on Ruby 2.7 and up, including Ruby 3.x, and supports URLs of up to 255
|
|
10
10
|
|
11
11
|
## Usage
|
12
12
|
|
13
|
+
### Basic Usage
|
14
|
+
|
13
15
|
Reading a .webloc file:
|
14
16
|
|
15
|
-
Webloc.load(
|
17
|
+
webloc = Webloc.load('bookmark.webloc')
|
18
|
+
puts webloc.url
|
19
|
+
# => "https://example.com"
|
16
20
|
|
17
21
|
Writing to a .webloc file:
|
18
22
|
|
19
23
|
Webloc.new('https://rubyweekly.com/').save('rubyweekly.webloc')
|
20
24
|
|
25
|
+
### Advanced Examples
|
26
|
+
|
27
|
+
#### Processing multiple .webloc files
|
28
|
+
|
29
|
+
```ruby
|
30
|
+
require 'webloc'
|
31
|
+
|
32
|
+
Dir.glob('*.webloc').each do |file|
|
33
|
+
webloc = Webloc.load(file)
|
34
|
+
puts "#{file}: #{webloc.url}"
|
35
|
+
end
|
36
|
+
```
|
37
|
+
|
38
|
+
#### Creating webloc files from a list of URLs
|
39
|
+
|
40
|
+
```ruby
|
41
|
+
require 'webloc'
|
42
|
+
|
43
|
+
urls = [
|
44
|
+
'https://github.com',
|
45
|
+
'https://stackoverflow.com',
|
46
|
+
'https://ruby-lang.org'
|
47
|
+
]
|
48
|
+
|
49
|
+
urls.each_with_index do |url, index|
|
50
|
+
filename = "bookmark_#{index + 1}.webloc"
|
51
|
+
Webloc.new(url).save(filename)
|
52
|
+
puts "Created #{filename}"
|
53
|
+
end
|
54
|
+
```
|
55
|
+
|
56
|
+
#### Error handling
|
57
|
+
|
58
|
+
```ruby
|
59
|
+
require 'webloc'
|
60
|
+
|
61
|
+
begin
|
62
|
+
webloc = Webloc.load('suspicious.webloc')
|
63
|
+
puts webloc.url
|
64
|
+
rescue Webloc::FileNotFoundError => e
|
65
|
+
puts "File not found: #{e.message}"
|
66
|
+
rescue Webloc::CorruptedFileError => e
|
67
|
+
puts "File is corrupted: #{e.message}"
|
68
|
+
rescue Webloc::InvalidFormatError => e
|
69
|
+
puts "Invalid file format: #{e.message}"
|
70
|
+
rescue Webloc::WeblocError => e
|
71
|
+
puts "General webloc error: #{e.message}"
|
72
|
+
end
|
73
|
+
```
|
74
|
+
|
75
|
+
#### Validating URLs before creating webloc files
|
76
|
+
|
77
|
+
```ruby
|
78
|
+
require 'webloc'
|
79
|
+
require 'uri'
|
80
|
+
|
81
|
+
def create_webloc_safely(url, filename)
|
82
|
+
# Basic URL validation
|
83
|
+
uri = URI.parse(url)
|
84
|
+
unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
85
|
+
puts "Invalid URL scheme: #{url}"
|
86
|
+
return false
|
87
|
+
end
|
88
|
+
|
89
|
+
# Create the webloc file
|
90
|
+
Webloc.new(url).save(filename)
|
91
|
+
puts "Created #{filename} for #{url}"
|
92
|
+
true
|
93
|
+
rescue URI::InvalidURIError
|
94
|
+
puts "Invalid URL format: #{url}"
|
95
|
+
false
|
96
|
+
rescue Webloc::WeblocError => e
|
97
|
+
puts "Failed to create webloc: #{e.message}"
|
98
|
+
false
|
99
|
+
end
|
100
|
+
|
101
|
+
create_webloc_safely('https://example.com', 'example.webloc')
|
102
|
+
create_webloc_safely('invalid-url', 'invalid.webloc')
|
103
|
+
```
|
104
|
+
|
105
|
+
#### Converting between formats
|
106
|
+
|
107
|
+
```ruby
|
108
|
+
require 'webloc'
|
109
|
+
require 'json'
|
110
|
+
|
111
|
+
# Convert webloc to JSON
|
112
|
+
webloc = Webloc.load('bookmark.webloc')
|
113
|
+
json_data = { url: webloc.url, title: File.basename('bookmark.webloc', '.webloc') }
|
114
|
+
File.write('bookmark.json', JSON.pretty_generate(json_data))
|
115
|
+
|
116
|
+
# Convert JSON back to webloc
|
117
|
+
json_content = JSON.parse(File.read('bookmark.json'))
|
118
|
+
Webloc.new(json_content['url']).save('restored.webloc')
|
119
|
+
```
|
120
|
+
|
21
121
|
## Thanks
|
22
122
|
|
23
123
|
Thanks is due to Christos Karaiskos for [this article](https://medium.com/@karaiskc/understanding-apples-binary-property-list-format-281e6da00dbd
|
@@ -25,6 +125,6 @@ Thanks is due to Christos Karaiskos for [this article](https://medium.com/@karai
|
|
25
125
|
|
26
126
|
## License
|
27
127
|
|
28
|
-
Copyright (C) 2011-
|
128
|
+
Copyright (C) 2011-2025 Peter Cooper
|
29
129
|
|
30
130
|
webloc is licensed under the terms of the MIT License
|
data/lib/webloc/version.rb
CHANGED
data/lib/webloc.rb
CHANGED
@@ -1,39 +1,115 @@
|
|
1
1
|
require 'plist'
|
2
2
|
|
3
3
|
class Webloc
|
4
|
+
class WeblocError < StandardError; end
|
5
|
+
class FileNotFoundError < WeblocError; end
|
6
|
+
class CorruptedFileError < WeblocError; end
|
7
|
+
class InvalidFormatError < WeblocError; end
|
8
|
+
class EmptyFileError < WeblocError; end
|
9
|
+
|
4
10
|
attr_accessor :url
|
5
11
|
|
6
12
|
def initialize(url)
|
13
|
+
raise ArgumentError, "URL cannot be nil or empty" if url.nil? || url.empty?
|
7
14
|
@url = url
|
8
15
|
end
|
9
16
|
|
10
17
|
def self.load(filename)
|
11
|
-
|
18
|
+
raise FileNotFoundError, "File not found: #{filename}" unless File.exist?(filename)
|
19
|
+
|
20
|
+
begin
|
21
|
+
data = File.read(filename)
|
22
|
+
rescue => e
|
23
|
+
raise FileNotFoundError, "Unable to read file '#{filename}': #{e.message}"
|
24
|
+
end
|
25
|
+
|
26
|
+
raise EmptyFileError, "File is empty: #{filename}" if data.empty?
|
27
|
+
|
12
28
|
data = data.force_encoding('binary') rescue data
|
29
|
+
url = nil
|
13
30
|
|
14
31
|
if data !~ /\<plist/
|
15
|
-
|
16
|
-
|
17
|
-
length = length.ord rescue length
|
18
|
-
url = data[offset + 7,length]
|
32
|
+
# Handle binary plist format
|
33
|
+
url = parse_binary_format(data, filename)
|
19
34
|
else
|
20
|
-
|
35
|
+
# Handle XML plist format
|
36
|
+
url = parse_xml_format(filename)
|
21
37
|
end
|
22
38
|
|
23
|
-
raise
|
39
|
+
raise CorruptedFileError, "No URL found in webloc file: #{filename}" if url.nil? || url.empty?
|
24
40
|
new(url)
|
25
41
|
end
|
26
42
|
|
43
|
+
private
|
44
|
+
|
45
|
+
def self.parse_binary_format(data, filename)
|
46
|
+
offset = (data =~ /SURL_/)
|
47
|
+
raise InvalidFormatError, "Invalid binary webloc format - missing SURL marker in file: #{filename}" unless offset
|
48
|
+
|
49
|
+
begin
|
50
|
+
length_offset = 7
|
51
|
+
if data[offset + 5] == "\x10"
|
52
|
+
length = data[offset + 6]
|
53
|
+
length = length.unpack('C')[0]
|
54
|
+
elsif data[offset + 5] == "\x11"
|
55
|
+
length_offset = 8
|
56
|
+
length = data[offset + 6] + data[offset + 7]
|
57
|
+
length = length.unpack('S>')[0]
|
58
|
+
else
|
59
|
+
raise InvalidFormatError, "Unsupported length encoding in binary webloc file: #{filename}"
|
60
|
+
end
|
61
|
+
|
62
|
+
raise CorruptedFileError, "Invalid URL length (#{length}) in file: #{filename}" if length <= 0 || length > data.length
|
63
|
+
|
64
|
+
url = data[offset + length_offset, length]
|
65
|
+
raise CorruptedFileError, "Extracted URL is empty from file: #{filename}" if url.nil? || url.empty?
|
66
|
+
|
67
|
+
url
|
68
|
+
rescue CorruptedFileError, InvalidFormatError => e
|
69
|
+
raise e
|
70
|
+
rescue => e
|
71
|
+
raise CorruptedFileError, "Failed to parse binary webloc format in file '#{filename}': #{e.message}"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def self.parse_xml_format(filename)
|
76
|
+
begin
|
77
|
+
plist_data = Plist::parse_xml(filename)
|
78
|
+
raise InvalidFormatError, "Invalid XML plist format - could not parse file: #{filename}" unless plist_data.is_a?(Hash)
|
79
|
+
|
80
|
+
url = plist_data['URL']
|
81
|
+
raise CorruptedFileError, "No 'URL' key found in plist file: #{filename}" unless url
|
82
|
+
|
83
|
+
url
|
84
|
+
rescue => e
|
85
|
+
if e.message.include?('parse') || e.message.include?('XML') || e.message.include?('plist')
|
86
|
+
raise InvalidFormatError, "Invalid XML plist format in file '#{filename}': #{e.message}"
|
87
|
+
else
|
88
|
+
raise CorruptedFileError, "Failed to parse XML webloc format in file '#{filename}': #{e.message}"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
public
|
94
|
+
|
27
95
|
def data
|
28
96
|
# PLIST HEADER
|
29
97
|
@data = "bplist\x30\x30".bytes
|
30
98
|
|
31
99
|
# PLIST OBJECT TABLE
|
32
100
|
@data += "\xD1\x01\x02".bytes # object 1 is a dictionary
|
33
|
-
@data += "
|
34
|
-
|
35
|
-
|
36
|
-
@data +=
|
101
|
+
@data += "SURL".bytes # object 2
|
102
|
+
|
103
|
+
length_suffix = @url.length > 255 ? "\x11" : "\x10"
|
104
|
+
@data += ("\x5f" + length_suffix).bytes # object 3 is an ASCII string with a variable length length encoding (I know..)
|
105
|
+
# .. the '0' in \x10 denotes the length can be encoded within 2**0 bytes (i.e. 1)
|
106
|
+
# .. the '1' in \x11 denotes the length can be encoded within 2**1 bytes (i.e. 2)
|
107
|
+
|
108
|
+
if @url.length > 255
|
109
|
+
@data += [@url.length].pack('S>').bytes
|
110
|
+
else
|
111
|
+
@data += [@url.length].pack('C').bytes
|
112
|
+
end
|
37
113
|
@data += @url.bytes # and finally the URL itself
|
38
114
|
|
39
115
|
# This is the offset table
|
@@ -52,12 +128,18 @@ class Webloc
|
|
52
128
|
# Bytes 16-23 are for an offset from the offset table
|
53
129
|
@data += "\x00\x00\x00\x00\x00\x00\x00\x00".bytes
|
54
130
|
# Bytes 24-31 denote the position of the offset table from the start of the file
|
55
|
-
@data += "\x00\x00\x00\x00\x00\x00
|
131
|
+
@data += "\x00\x00\x00\x00\x00\x00".bytes + [@url.length + 18].pack('S>').bytes
|
56
132
|
|
57
133
|
@data = @data.pack('C*')
|
58
134
|
end
|
59
135
|
|
60
136
|
def save(filename)
|
61
|
-
|
137
|
+
raise ArgumentError, "Filename cannot be nil or empty" if filename.nil? || filename.empty?
|
138
|
+
|
139
|
+
begin
|
140
|
+
File.open(filename, 'wb') { |f| f.write data }
|
141
|
+
rescue => e
|
142
|
+
raise WeblocError, "Failed to save webloc file '#{filename}': #{e.message}"
|
143
|
+
end
|
62
144
|
end
|
63
145
|
end
|
data/test/webloc_test.rb
CHANGED
@@ -6,6 +6,16 @@ class WeblocTest < Test::Unit::TestCase
|
|
6
6
|
def test_webloc_object_requires_url
|
7
7
|
assert_raise(ArgumentError) { Webloc.new }
|
8
8
|
end
|
9
|
+
|
10
|
+
def test_webloc_object_rejects_nil_url
|
11
|
+
error = assert_raise(ArgumentError) { Webloc.new(nil) }
|
12
|
+
assert_equal "URL cannot be nil or empty", error.message
|
13
|
+
end
|
14
|
+
|
15
|
+
def test_webloc_object_rejects_empty_url
|
16
|
+
error = assert_raise(ArgumentError) { Webloc.new("") }
|
17
|
+
assert_equal "URL cannot be nil or empty", error.message
|
18
|
+
end
|
9
19
|
|
10
20
|
def test_webloc_object_created_with_url
|
11
21
|
assert_equal 'http://example.com', Webloc.new('http://example.com').url
|
@@ -25,8 +35,16 @@ class WeblocTest < Test::Unit::TestCase
|
|
25
35
|
end
|
26
36
|
|
27
37
|
def test_webloc_can_handle_long_urls
|
28
|
-
url = "http://example.com/this-is-a-very-long-url-
|
38
|
+
url = "http://example.com/this-is-a-very-long-url-abcde" + ('a' * 2000)
|
29
39
|
assert_nothing_raised { Webloc.new(url).data }
|
40
|
+
file = Tempfile.new('test-long-webloc')
|
41
|
+
begin
|
42
|
+
Webloc.new(url).save(file.path)
|
43
|
+
assert_equal url, Webloc.load(file.path).url
|
44
|
+
ensure
|
45
|
+
file.close
|
46
|
+
file.unlink
|
47
|
+
end
|
30
48
|
end
|
31
49
|
|
32
50
|
def test_webloc_can_write_file
|
@@ -39,4 +57,74 @@ class WeblocTest < Test::Unit::TestCase
|
|
39
57
|
file.unlink
|
40
58
|
end
|
41
59
|
end
|
60
|
+
|
61
|
+
def test_load_nonexistent_file_raises_file_not_found_error
|
62
|
+
error = assert_raise(Webloc::FileNotFoundError) { Webloc.load('nonexistent.webloc') }
|
63
|
+
assert_match(/File not found: nonexistent\.webloc/, error.message)
|
64
|
+
end
|
65
|
+
|
66
|
+
def test_load_empty_file_raises_empty_file_error
|
67
|
+
file = Tempfile.new('empty-webloc')
|
68
|
+
begin
|
69
|
+
file.close
|
70
|
+
error = assert_raise(Webloc::EmptyFileError) { Webloc.load(file.path) }
|
71
|
+
assert_match(/File is empty:/, error.message)
|
72
|
+
ensure
|
73
|
+
file.unlink
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def test_load_corrupted_binary_file_raises_invalid_format_error
|
78
|
+
file = Tempfile.new('corrupted-webloc')
|
79
|
+
begin
|
80
|
+
file.write("corrupted binary data without SURL marker")
|
81
|
+
file.close
|
82
|
+
error = assert_raise(Webloc::InvalidFormatError) { Webloc.load(file.path) }
|
83
|
+
assert_match(/Invalid binary webloc format - missing SURL marker/, error.message)
|
84
|
+
ensure
|
85
|
+
file.unlink
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def test_load_invalid_xml_file_raises_invalid_format_error
|
90
|
+
file = Tempfile.new('invalid-xml-webloc')
|
91
|
+
begin
|
92
|
+
file.write("<plist><invalid>xml</invalid>")
|
93
|
+
file.close
|
94
|
+
error = assert_raise(Webloc::InvalidFormatError) { Webloc.load(file.path) }
|
95
|
+
assert_match(/Invalid XML plist format/, error.message)
|
96
|
+
ensure
|
97
|
+
file.unlink
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def test_load_xml_without_url_key_raises_invalid_format_error
|
102
|
+
file = Tempfile.new('no-url-xml-webloc')
|
103
|
+
begin
|
104
|
+
file.write('<?xml version="1.0"?><plist><dict><key>NotURL</key><string>value</string></dict></plist>')
|
105
|
+
file.close
|
106
|
+
error = assert_raise(Webloc::InvalidFormatError) { Webloc.load(file.path) }
|
107
|
+
assert_match(/No 'URL' key found in plist file/, error.message)
|
108
|
+
ensure
|
109
|
+
file.unlink
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def test_save_with_nil_filename_raises_argument_error
|
114
|
+
webloc = Webloc.new('http://example.com')
|
115
|
+
error = assert_raise(ArgumentError) { webloc.save(nil) }
|
116
|
+
assert_equal "Filename cannot be nil or empty", error.message
|
117
|
+
end
|
118
|
+
|
119
|
+
def test_save_with_empty_filename_raises_argument_error
|
120
|
+
webloc = Webloc.new('http://example.com')
|
121
|
+
error = assert_raise(ArgumentError) { webloc.save("") }
|
122
|
+
assert_equal "Filename cannot be nil or empty", error.message
|
123
|
+
end
|
124
|
+
|
125
|
+
def test_save_to_invalid_path_raises_webloc_error
|
126
|
+
webloc = Webloc.new('http://example.com')
|
127
|
+
error = assert_raise(Webloc::WeblocError) { webloc.save('/invalid/path/that/does/not/exist/file.webloc') }
|
128
|
+
assert_match(/Failed to save webloc file/, error.message)
|
129
|
+
end
|
42
130
|
end
|
metadata
CHANGED
@@ -1,14 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: webloc
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Peter Cooper
|
8
|
-
autorequire:
|
9
8
|
bindir: bin
|
10
9
|
cert_chain: []
|
11
|
-
date:
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
12
11
|
dependencies:
|
13
12
|
- !ruby/object:Gem::Dependency
|
14
13
|
name: plist
|
@@ -45,7 +44,6 @@ files:
|
|
45
44
|
homepage: https://github.com/peterc/webloc
|
46
45
|
licenses: []
|
47
46
|
metadata: {}
|
48
|
-
post_install_message:
|
49
47
|
rdoc_options: []
|
50
48
|
require_paths:
|
51
49
|
- lib
|
@@ -60,8 +58,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
60
58
|
- !ruby/object:Gem::Version
|
61
59
|
version: '0'
|
62
60
|
requirements: []
|
63
|
-
rubygems_version: 3.
|
64
|
-
signing_key:
|
61
|
+
rubygems_version: 3.6.7
|
65
62
|
specification_version: 4
|
66
63
|
summary: Reads and writes .webloc files on macOS
|
67
64
|
test_files:
|