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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8ff9f7c12df7756811c14678b08b8ab6e42839dbb687b802ad37addf372f82d
4
- data.tar.gz: 71f8eec3e245afe13e755127b519999255547829f3903f7d70b90099434a7493
3
+ metadata.gz: c818dbf002be73a6bcf36920bfae42c98bd5185d8076e5835773f3b08f41fc69
4
+ data.tar.gz: 78982a897fce013b29b15e66a64cdd003de478040d6399245942fd49858383e0
5
5
  SHA512:
6
- metadata.gz: 82233db761cc8a120f29e0f98f6a285cf2184f3ec9c865feaef060deb1b6021682a604dc1f352ea9af3b9d81fc2dd7fab04a3d3f18e8b923e0f310eeb3e680ae
7
- data.tar.gz: ce6d621dda047bd22bbc68353f031bcc91d579f3a46c0c2e049606bf9f778fbcb9d3a0ee03d87eef5c4ac3699ba351eb2be99d60a06500c9cef3cd95c036f2d7
6
+ metadata.gz: 659e74f401a6ce48cac21e72bcf0b07e1c2ac2214579ed700d7715efbff9d68da082a043a5036193cd7e792d7f3a8a38a2ef6ac2780951129f1aa37781e74e8f
7
+ data.tar.gz: 5799dbc3570c7395545a710f2f2f8bc4bb6f5cb79a681c4a9cb862647a489070fbdc35932a474d2fdac5253f9a090087da179cfe4e6365e32a1eb5efa26d5c0f
data/.gitignore CHANGED
@@ -1,3 +1,4 @@
1
1
  pkg/*
2
2
  *.gem
3
3
  .bundle
4
+ .DS_Store
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 255 characters in length.
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(ARGV.first).url
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-2024 Peter Cooper
128
+ Copyright (C) 2011-2025 Peter Cooper
29
129
 
30
130
  webloc is licensed under the terms of the MIT License
@@ -1,3 +1,3 @@
1
1
  class Webloc
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.1"
3
3
  end
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
- data = File.read(filename)
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
- offset = (data =~ /SURL_/)
16
- length = data[offset + 6]
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
- url = Plist::parse_xml(filename)['URL'] rescue nil
35
+ # Handle XML plist format
36
+ url = parse_xml_format(filename)
21
37
  end
22
38
 
23
- raise ArgumentError unless url
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 += "\x53URL".bytes # object 2 is an ASCII string of length 3
34
- @data += "\x5f\x10".bytes # object 3 is an ASCII string with a variable length length encoding (I know..)
35
- # .. the '0' in \x10 denotes the length can be encoded within 2**0 bytes (i.e. 1!)
36
- @data += @url.length.chr.bytes # and here is that one byte..
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\x00".bytes + (@url.length + 18).chr.bytes
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
- File.open(filename, 'wb') { |f| f.write data }
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-indeed-it-should-easily-go-over-110-characters-for-our-testing-purposes"
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.2.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: 2024-01-26 00:00:00.000000000 Z
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.2.22
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: