closes #4523
[arvados.git] / services / api / app / models / collection.rb
index f15d0310d2deff3307357e5e6db48e093f883aeb..8258d1f53cd5a39495b2a30493492f3a416807c7 100644 (file)
@@ -1,27 +1,34 @@
+require 'arvados/keep'
+
 class Collection < ArvadosModel
   include HasUuid
   include KindAndEtag
   include CommonApiTemplate
 
+  before_validation :check_encoding
   before_validation :check_signatures
   before_validation :strip_manifest_text
   before_validation :set_portable_data_hash
   validate :ensure_hash_matches_manifest_text
+  before_save :set_file_names
+
+  # Query only undeleted collections by default.
+  default_scope where("expires_at IS NULL or expires_at > CURRENT_TIMESTAMP")
 
   api_accessible :user, extend: :common do |t|
-    t.add :data_size
-    t.add :files
     t.add :name
     t.add :description
     t.add :properties
     t.add :portable_data_hash
-    t.add :manifest_text
+    t.add :signed_manifest_text, as: :manifest_text
   end
 
   def self.attributes_required_columns
-    super.merge({ "data_size" => ["manifest_text"],
-                  "files" => ["manifest_text"],
-                })
+    # If we don't list this explicitly, the params[:select] code gets
+    # confused by the way we expose signed_manifest_text as
+    # manifest_text in the API response, and never let clients select
+    # the manifest_text column.
+    super.merge('manifest_text' => ['manifest_text'])
   end
 
   def check_signatures
@@ -29,6 +36,13 @@ class Collection < ArvadosModel
 
     return true if current_user.andand.is_admin
 
+    # Provided the manifest_text hasn't changed materially since an
+    # earlier validation, it's safe to pass this validation on
+    # subsequent passes without checking any signatures. This is
+    # important because the signatures have probably been stripped off
+    # by the time we get to a second validation pass!
+    return true if @signatures_checked and @signatures_checked == compute_pdh
+
     if self.manifest_text_changed?
       # Check permissions on the collection manifest.
       # If any signature cannot be verified, raise PermissionDeniedError
@@ -48,7 +62,7 @@ class Collection < ArvadosModel
             # filenames. Safety first!
           elsif Blob.verify_signature tok, signing_opts
             # OK.
-          elsif Locator.parse(tok).andand.signature
+          elsif Keep::Locator.parse(tok).andand.signature
             # Signature provided, but verify_signature did not like it.
             logger.warn "Invalid signature on locator #{tok}"
             raise ArvadosModel::PermissionDeniedError
@@ -64,13 +78,13 @@ class Collection < ArvadosModel
         end
       end
     end
-    true
+    @signatures_checked = compute_pdh
   end
 
   def strip_manifest_text
     if self.manifest_text_changed?
       # Remove any permission signatures from the manifest.
-      Collection.munge_manifest_locators(self[:manifest_text]) do |loc|
+      self.class.munge_manifest_locators!(self[:manifest_text]) do |loc|
         loc.without_signature.to_s
       end
     end
@@ -78,13 +92,21 @@ class Collection < ArvadosModel
   end
 
   def set_portable_data_hash
-    if (self.portable_data_hash.nil? or (self.portable_data_hash == "") or (manifest_text_changed? and !portable_data_hash_changed?))
-      self.portable_data_hash = "#{Digest::MD5.hexdigest(manifest_text)}+#{manifest_text.length}"
+    if (portable_data_hash.nil? or
+        portable_data_hash == "" or
+        (manifest_text_changed? and !portable_data_hash_changed?))
+      @need_pdh_validation = false
+      self.portable_data_hash = compute_pdh
     elsif portable_data_hash_changed?
+      @need_pdh_validation = true
       begin
-        loc = Locator.parse!(self.portable_data_hash)
+        loc = Keep::Locator.parse!(self.portable_data_hash)
         loc.strip_hints!
-        self.portable_data_hash = loc.to_s
+        if loc.size
+          self.portable_data_hash = loc.to_s
+        else
+          self.portable_data_hash = "#{loc.hash}+#{portable_manifest_text.bytesize}"
+        end
       rescue ArgumentError => e
         errors.add(:portable_data_hash, "#{e}")
         return false
@@ -94,17 +116,65 @@ class Collection < ArvadosModel
   end
 
   def ensure_hash_matches_manifest_text
-    if manifest_text_changed? or portable_data_hash_changed?
-      computed_hash = "#{Digest::MD5.hexdigest(manifest_text)}+#{manifest_text.length}"
-      unless computed_hash == portable_data_hash
-        logger.debug "(computed) '#{computed_hash}' != '#{portable_data_hash}' (provided)"
-        errors.add(:portable_data_hash, "does not match hash of manifest_text")
-        return false
-      end
+    return true unless manifest_text_changed? or portable_data_hash_changed?
+    # No need verify it if :set_portable_data_hash just computed it!
+    return true if not @need_pdh_validation
+    expect_pdh = compute_pdh
+    if expect_pdh != portable_data_hash
+      errors.add(:portable_data_hash,
+                 "does not match computed hash #{expect_pdh}")
+      return false
+    end
+  end
+
+  def set_file_names
+    if self.manifest_text_changed?
+      self.file_names = manifest_files
     end
     true
   end
 
+  def manifest_files
+    names = ''
+    if self.manifest_text
+      self.manifest_text.scan(/ \d+:\d+:(\S+)/) do |name|
+        names << name.first.gsub('\040',' ') + "\n"
+        break if names.length > 2**13
+      end
+    end
+
+    if self.manifest_text and names.length < 2**13
+      self.manifest_text.scan(/^\.\/(\S+)/m) do |stream_name|
+        names << stream_name.first.gsub('\040',' ') + "\n"
+        break if names.length > 2**13
+      end
+    end
+
+    names[0,2**13]
+  end
+
+  def check_encoding
+    if manifest_text.encoding.name == 'UTF-8' and manifest_text.valid_encoding?
+      true
+    else
+      begin
+        # If Ruby thinks the encoding is something else, like 7-bit
+        # ASCII, but its stored bytes are equal to the (valid) UTF-8
+        # encoding of the same string, we declare it to be a UTF-8
+        # string.
+        utf8 = manifest_text
+        utf8.force_encoding Encoding::UTF_8
+        if utf8.valid_encoding? and utf8 == manifest_text.encode(Encoding::UTF_8)
+          manifest_text = utf8
+          return true
+        end
+      rescue
+      end
+      errors.add :manifest_text, "must use UTF-8 encoding"
+      false
+    end
+  end
+
   def redundancy_status
     if redundancy_confirmed_as.nil?
       'unconfirmed'
@@ -121,81 +191,31 @@ class Collection < ArvadosModel
     end
   end
 
-  def data_size
-    inspect_manifest_text if @data_size.nil? or manifest_text_changed?
-    @data_size
-  end
-
-  def files
-    inspect_manifest_text if @files.nil? or manifest_text_changed?
-    @files
-  end
-
-  def inspect_manifest_text
-    if !manifest_text
-      @data_size = false
-      @files = []
-      return
-    end
-
-    @data_size = 0
-    tmp = {}
-
-    manifest_text.split("\n").each do |stream|
-      toks = stream.split(" ")
-
-      stream = toks[0].gsub /\\(\\|[0-7]{3})/ do |escape_sequence|
-        case $1
-        when '\\' '\\'
-        else $1.to_i(8).chr
-        end
-      end
-
-      toks[1..-1].each do |tok|
-        if (re = tok.match /^[0-9a-f]{32}/)
-          blocksize = nil
-          tok.split('+')[1..-1].each do |hint|
-            if !blocksize and hint.match /^\d+$/
-              blocksize = hint.to_i
-            end
-            if (re = hint.match /^GS(\d+)$/)
-              blocksize = re[1].to_i
-            end
-          end
-          @data_size = false if !blocksize
-          @data_size += blocksize if @data_size
-        else
-          if (re = tok.match /^(\d+):(\d+):(\S+)$/)
-            filename = re[3].gsub /\\(\\|[0-7]{3})/ do |escape_sequence|
-              case $1
-              when '\\' '\\'
-              else $1.to_i(8).chr
-              end
-            end
-            fn = stream + '/' + filename
-            i = re[2].to_i
-            if tmp[fn]
-              tmp[fn] += i
-            else
-              tmp[fn] = i
-            end
-          end
-        end
-      end
+  def signed_manifest_text
+    if has_attribute? :manifest_text
+      token = current_api_client_authorization.andand.api_token
+      @signed_manifest_text = self.class.sign_manifest manifest_text, token
     end
+  end
 
-    @files = []
-    tmp.each do |k, v|
-      re = k.match(/^(.+)\/(.+)/)
-      @files << [re[1], re[2], v]
+  def self.sign_manifest manifest, token
+    signing_opts = {
+      key: Rails.configuration.blob_signing_key,
+      api_token: token,
+      ttl: Rails.configuration.blob_signing_ttl,
+    }
+    m = manifest.dup
+    munge_manifest_locators!(m) do |loc|
+      Blob.sign_locator(loc.to_s, signing_opts)
     end
+    return m
   end
 
-  def self.munge_manifest_locators(manifest)
+  def self.munge_manifest_locators! manifest
     # Given a manifest text and a block, yield each locator,
     # and replace it with whatever the block returns.
     manifest.andand.gsub!(/ [[:xdigit:]]{32}(\+[[:digit:]]+)?(\+\S+)/) do |word|
-      if loc = Locator.parse(word.strip)
+      if loc = Keep::Locator.parse(word.strip)
         " " + yield(loc)
       else
         " " + word
@@ -230,12 +250,17 @@ class Collection < ArvadosModel
 
     # If the search term is a Collection locator that contains one file
     # that looks like a Docker image, return it.
-    if loc = Locator.parse(search_term)
+    if loc = Keep::Locator.parse(search_term)
       loc.strip_hints!
       coll_match = readable_by(*readers).where(portable_data_hash: loc.to_s).limit(1).first
-      if coll_match and (coll_match.files.size == 1) and
-          (coll_match.files[0][1] =~ /^[0-9A-Fa-f]{64}\.tar$/)
-        return [coll_match]
+      if coll_match
+        # Check if the Collection contains exactly one file whose name
+        # looks like a saved Docker image.
+        manifest = Keep::Manifest.new(coll_match.manifest_text)
+        if manifest.exact_file_count?(1) and
+            (manifest.files[0][1] =~ /^[0-9A-Fa-f]{64}\.tar$/)
+          return [coll_match]
+        end
       end
     end
 
@@ -270,4 +295,24 @@ class Collection < ArvadosModel
   def self.for_latest_docker_image(search_term, search_tag=nil, readers=nil)
     find_all_for_docker_image(search_term, search_tag, readers).first
   end
+
+  def self.searchable_columns operator
+    super - ["manifest_text"]
+  end
+
+  protected
+  def portable_manifest_text
+    portable_manifest = self[:manifest_text].dup
+    self.class.munge_manifest_locators!(portable_manifest) do |loc|
+      loc.hash + '+' + loc.size.to_s
+    end
+    portable_manifest
+  end
+
+  def compute_pdh
+    portable_manifest = portable_manifest_text
+    (Digest::MD5.hexdigest(portable_manifest) +
+     '+' +
+     portable_manifest.bytesize.to_s)
+  end
 end